|
| 1 | +import type { PropsWithChildren, ReactElement } from "react"; |
| 2 | + |
| 3 | +import tailwind from "@tailwindcss/postcss"; |
| 4 | +import { |
| 5 | + screen, |
| 6 | + render as tlRender, |
| 7 | + type RenderOptions, |
| 8 | +} from "@testing-library/react-native"; |
| 9 | +import postcss from "postcss"; |
| 10 | +import { registerCSS } from "react-native-css/jest"; |
| 11 | + |
| 12 | +import type { compile } from "../../../compiler"; |
| 13 | +import { View } from "../../../components"; |
| 14 | + |
| 15 | +const testID = "tailwind"; |
| 16 | + |
| 17 | +export type NativewindRenderOptions = RenderOptions & { |
| 18 | + /** Replace the generated CSS*/ |
| 19 | + css?: string; |
| 20 | + /** Appended after the generated CSS */ |
| 21 | + extraCss?: string; |
| 22 | + /** Specify the className to use for the component @default sourceInline */ |
| 23 | + className?: string; |
| 24 | + /** Add `@source inline('<className>')` to the CSS. @default Values are extracted from the component's className */ |
| 25 | + sourceInline?: string[]; |
| 26 | + /** Whether to include the theme in the generated CSS @default true */ |
| 27 | + theme?: boolean; |
| 28 | + /** Whether to include the preflight in the generated CSS @default false */ |
| 29 | + preflight?: boolean; |
| 30 | + /** Whether to include the plugin in the generated CSS. @default true */ |
| 31 | + plugin?: boolean; |
| 32 | + /** Enable debug logging. @default false - Set process.env.NATIVEWIND_TEST_AUTO_DEBUG and run tests with the node inspector */ |
| 33 | + debug?: boolean; |
| 34 | +}; |
| 35 | + |
| 36 | +const debugDefault = Boolean(process.env.NODE_OPTIONS?.includes("--inspect")); |
| 37 | + |
| 38 | +export async function render( |
| 39 | + component: ReactElement<PropsWithChildren>, |
| 40 | + { |
| 41 | + css, |
| 42 | + sourceInline = Array.from(getClassNames(component)), |
| 43 | + debug = debugDefault, |
| 44 | + theme = true, |
| 45 | + preflight = false, |
| 46 | + extraCss, |
| 47 | + ...options |
| 48 | + }: NativewindRenderOptions = {}, |
| 49 | +): Promise<ReturnType<typeof tlRender> & ReturnType<typeof compile>> { |
| 50 | + if (!css) { |
| 51 | + css = ``; |
| 52 | + |
| 53 | + if (theme) { |
| 54 | + css += `@import "tailwindcss/theme.css" layer(theme);\n`; |
| 55 | + } |
| 56 | + |
| 57 | + if (preflight) { |
| 58 | + css += `@import "tailwindcss/preflight.css" layer(base);\n`; |
| 59 | + } |
| 60 | + |
| 61 | + css += `@import "tailwindcss/utilities.css" layer(utilities) source(none);\n`; |
| 62 | + } |
| 63 | + |
| 64 | + css += sourceInline |
| 65 | + .map((source) => `@source inline("${source}");`) |
| 66 | + .join("\n"); |
| 67 | + |
| 68 | + if (extraCss) { |
| 69 | + css += `\n${extraCss}`; |
| 70 | + } |
| 71 | + |
| 72 | + if (debug) { |
| 73 | + console.log(`Input CSS:\n---\n${css}\n---\n`); |
| 74 | + } |
| 75 | + |
| 76 | + // Process the TailwindCSS |
| 77 | + const { css: output } = await postcss([ |
| 78 | + /* Tailwind seems to internally cache things, so we need a random value to cache bust */ |
| 79 | + tailwind({ base: Date.now().toString() }), |
| 80 | + ]).process(css, { |
| 81 | + from: __dirname, |
| 82 | + }); |
| 83 | + |
| 84 | + if (debug) { |
| 85 | + console.log(`Output CSS:\n---\n${output}\n---\n`); |
| 86 | + } |
| 87 | + |
| 88 | + const compiled = registerCSS(output, { debug }); |
| 89 | + |
| 90 | + return Object.assign( |
| 91 | + {}, |
| 92 | + tlRender(component, { |
| 93 | + ...options, |
| 94 | + }), |
| 95 | + compiled, |
| 96 | + ); |
| 97 | +} |
| 98 | + |
| 99 | +render.debug = ( |
| 100 | + component: ReactElement<PropsWithChildren>, |
| 101 | + options: RenderOptions = {}, |
| 102 | +) => { |
| 103 | + return render(component, { ...options, debug: true }); |
| 104 | +}; |
| 105 | + |
| 106 | +function getClassNames( |
| 107 | + component: ReactElement<PropsWithChildren>, |
| 108 | + classNames = new Set<string>(), |
| 109 | +) { |
| 110 | + if ( |
| 111 | + typeof component.props === "object" && |
| 112 | + "className" in component.props && |
| 113 | + typeof component.props.className === "string" |
| 114 | + ) { |
| 115 | + classNames.add(component.props.className); |
| 116 | + } |
| 117 | + |
| 118 | + if (component.props.children) { |
| 119 | + const children: ReactElement[] = Array.isArray(component.props.children) |
| 120 | + ? component.props.children |
| 121 | + : [component.props.children]; |
| 122 | + |
| 123 | + for (const child of children) { |
| 124 | + getClassNames(child as ReactElement<PropsWithChildren>, classNames); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + return classNames; |
| 129 | +} |
| 130 | + |
| 131 | +export async function renderSimple({ |
| 132 | + className, |
| 133 | + ...options |
| 134 | +}: NativewindRenderOptions & { className: string }) { |
| 135 | + const { warnings: warningFn } = await render( |
| 136 | + <View testID={testID} className={className} />, |
| 137 | + options, |
| 138 | + ); |
| 139 | + const component = screen.getByTestId(testID, { hidden: true }); |
| 140 | + |
| 141 | + // Strip the testID and the children |
| 142 | + const { testID: _testID, children, ...props } = component.props; |
| 143 | + |
| 144 | + const compilerWarnings = warningFn(); |
| 145 | + |
| 146 | + let warnings: Record<string, unknown> | undefined; |
| 147 | + |
| 148 | + if (compilerWarnings.properties) { |
| 149 | + warnings ??= {}; |
| 150 | + warnings.properties = compilerWarnings.properties; |
| 151 | + } |
| 152 | + |
| 153 | + const warningValues = compilerWarnings.values; |
| 154 | + |
| 155 | + if (warningValues) { |
| 156 | + warnings ??= {}; |
| 157 | + warnings.values = Object.fromEntries( |
| 158 | + Object.entries(warningValues).map(([key, value]) => [ |
| 159 | + key, |
| 160 | + value.length > 1 ? value : value[0], |
| 161 | + ]), |
| 162 | + ); |
| 163 | + } |
| 164 | + |
| 165 | + return warnings ? { props, warnings } : { props }; |
| 166 | +} |
| 167 | + |
| 168 | +renderSimple.debug = ( |
| 169 | + options: NativewindRenderOptions & { className: string }, |
| 170 | +) => { |
| 171 | + return renderSimple({ ...options, debug: true }); |
| 172 | +}; |
| 173 | + |
| 174 | +/** |
| 175 | + * Helper method that uses the current test name to render the component |
| 176 | + * Doesn't not support multiple components or changing the component type |
| 177 | + */ |
| 178 | +export async function renderCurrentTest({ |
| 179 | + sourceInline = [expect.getState().currentTestName?.split(/\s+/).at(-1) ?? ""], |
| 180 | + className = sourceInline.join(" "), |
| 181 | + ...options |
| 182 | +}: NativewindRenderOptions = {}) { |
| 183 | + return renderSimple({ |
| 184 | + ...options, |
| 185 | + sourceInline, |
| 186 | + className, |
| 187 | + }); |
| 188 | +} |
| 189 | + |
| 190 | +renderCurrentTest.debug = (options: NativewindRenderOptions = {}) => { |
| 191 | + return renderCurrentTest({ ...options, debug: true }); |
| 192 | +}; |
0 commit comments