diff --git a/.flowconfig b/.flowconfig index 22d54b035acf..477771c0eba7 100644 --- a/.flowconfig +++ b/.flowconfig @@ -68,7 +68,7 @@ module.name_mapper='^react-native/src/fb_internal/\(.*\)$' -> '/pa # `src/fb_internal/*` stay importable. module.name_mapper='^react-native/\(Libraries\|src/private\|src/types\)/.*$' -> 'react-native-deep-import-is-not-part-of-the-public-api' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' -module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' +module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|ttf\|heif\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' module.system.haste.module_ref_prefix=m# diff --git a/.github/actions/run-fantom-tests/action.yml b/.github/actions/run-fantom-tests/action.yml index 68fc0adc8200..9894b19f03e6 100644 --- a/.github/actions/run-fantom-tests/action.yml +++ b/.github/actions/run-fantom-tests/action.yml @@ -38,6 +38,14 @@ runs: exit 1 env: LD_LIBRARY_PATH: ${{ github.workspace }}/private/react-native-fantom/build/tester + - name: Upload failed screenshot comparisons + if: ${{ always() }} + uses: ./.github/actions/upload-artifact + with: + name: fantom-screenshot-failures + compression-level: 1 + if-no-files-found: ignore + path: private/react-native-fantom/build/screenshot-failures - name: Upload test results if: ${{ always() }} uses: ./.github/actions/upload-artifact diff --git a/flow-typed/npm/jest-reporters_v29.x.x.js b/flow-typed/npm/jest-reporters_v29.x.x.js new file mode 100644 index 000000000000..01246db8b6ba --- /dev/null +++ b/flow-typed/npm/jest-reporters_v29.x.x.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +declare module '@jest/reporters' { + declare export class DefaultReporter< + TTestResult = unknown, + TAggregatedResult = unknown, + > { + constructor(globalConfig: unknown): void; + onRunStart(aggregatedResults: unknown, options: unknown): void; + onTestResult( + test: unknown, + result: TTestResult, + aggregatedResults: TAggregatedResult, + ): void | Promise; + onRunComplete( + testContexts: unknown, + aggregatedResults: TAggregatedResult, + ): void; + } + declare export class VerboseReporter< + TTestResult = unknown, + TAggregatedResult = unknown, + > extends DefaultReporter {} + declare export class SummaryReporter { + constructor(globalConfig: unknown): void; + onRunStart(aggregatedResults: unknown, options: unknown): void; + onRunComplete( + testContexts: unknown, + aggregatedResults: TAggregatedResult, + ): void; + } +} diff --git a/flow-typed/npm/pngjs_v7.x.x.js b/flow-typed/npm/pngjs_v7.x.x.js new file mode 100644 index 000000000000..39ad8a87b324 --- /dev/null +++ b/flow-typed/npm/pngjs_v7.x.x.js @@ -0,0 +1,17 @@ +/** + * @flow strict + * @format + */ + +declare module 'pngjs/lib/png' { + declare export class PNG { + constructor(options: {width: number, height: number}): void; + width: number; + height: number; + data: Buffer; + static sync: { + read(buffer: Buffer): PNG, + write(image: PNG): Buffer, + }; + } +} diff --git a/package.json b/package.json index 0c43364ce1f9..08c3501ce6f6 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-flow": "^7.24.7", "@electron/packager": "^20.0.0", + "@expo-google-fonts/noto-color-emoji": "0.4.7", "@expo/spawn-async": "^1.7.2", "@jest/create-cache-key-function": "^29.7.0", "@jest/test-sequencer": "^29.7.0", @@ -82,6 +83,7 @@ "connect": "^3.6.5", "debug": "^4.4.0", "deep-equal": "1.1.1", + "dejavu-fonts-ttf": "2.37.3", "eslint": "^8.57.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-babel": "^5.3.1", diff --git a/packages/new-app-screen/src/NewAppScreen.js b/packages/new-app-screen/src/NewAppScreen.js index 21319a89de0b..1f712fdaf4b4 100644 --- a/packages/new-app-screen/src/NewAppScreen.js +++ b/packages/new-app-screen/src/NewAppScreen.js @@ -9,6 +9,7 @@ */ import Links from './Links'; +import RuntimeVersion from './RuntimeVersion'; import {ThemedText, useTheme} from './Theme'; import * as React from 'react'; import { @@ -135,8 +136,7 @@ function getHermesLabel(): React.Node { return ( - JS Engine: Hermes ( - {global.HermesInternal.getRuntimeProperties?.()['OSS Release Version']}) + JS Engine: Hermes ({RuntimeVersion.hermes}) ); } diff --git a/packages/new-app-screen/src/RuntimeVersion.js b/packages/new-app-screen/src/RuntimeVersion.js new file mode 100644 index 000000000000..12de14e8cee4 --- /dev/null +++ b/packages/new-app-screen/src/RuntimeVersion.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const RuntimeVersion: {hermes: string | void} = { + hermes: + global.HermesInternal?.getRuntimeProperties?.()?.['OSS Release Version'], +}; + +export default RuntimeVersion; diff --git a/packages/react-native/Libraries/Image/ImageTypes.flow.js b/packages/react-native/Libraries/Image/ImageTypes.flow.js index 13f1106f66d0..eb5e03e2e152 100644 --- a/packages/react-native/Libraries/Image/ImageTypes.flow.js +++ b/packages/react-native/Libraries/Image/ImageTypes.flow.js @@ -12,7 +12,7 @@ import type {HostInstance} from '../..'; import type {RootTag} from '../Types/RootTagTypes'; import type {ResolvedAssetSource} from './AssetSourceResolver'; import type {ImageProps as ImagePropsType} from './ImageProps'; -import type {ImageSource} from './ImageSource'; +import typeof resolveAssetSource from './resolveAssetSource'; import * as React from 'react'; @@ -59,7 +59,7 @@ type ImageComponentStaticsIOS = Readonly<{ /** * @see https://reactnative.dev/docs/image#resolveassetsource */ - resolveAssetSource(source: ImageSource): ?ImageResolvedAssetSource, + resolveAssetSource: resolveAssetSource, }>; type ImageComponentStaticsAndroid = Readonly<{ diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..4dc11bc1a41f 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -75,6 +75,7 @@ declare const $$index: { } declare const $$NativeDeviceInfo: typeof NativeModule_default declare const $$ReactFabric: typeof ReactFabric_default +declare const $$resolveAssetSource: typeof resolveAssetSource_default declare const $$ScrollViewContext: typeof ScrollViewContext_default declare const absoluteFill: AbsoluteFillStyle declare const AccessibilityInfo: typeof AccessibilityInfo_default @@ -917,6 +918,7 @@ declare type $$flattenStyle = typeof $$flattenStyle declare type $$index = typeof $$index declare type $$NativeDeviceInfo = typeof $$NativeDeviceInfo declare type $$ReactFabric = typeof $$ReactFabric +declare type $$resolveAssetSource = typeof $$resolveAssetSource declare type $$ScrollViewContext = typeof $$ScrollViewContext declare type absoluteFill = typeof absoluteFill declare type AbsoluteFillStyle = { @@ -2437,6 +2439,7 @@ declare type ImageComponentStaticsAndroid = Readonly< } > declare type ImageComponentStaticsIOS = { + readonly resolveAssetSource: resolveAssetSource getSize(uri: string): Promise getSize( uri: string, @@ -2466,7 +2469,6 @@ declare type ImageComponentStaticsIOS = { queryCache(urls: Array): Promise<{ [url: string]: "disk" | "disk/memory" | "memory" }> - resolveAssetSource(source: ImageSource): ImageResolvedAssetSource | undefined } declare type ImageErrorEvent = NativeSyntheticEvent< Readonly @@ -4116,6 +4118,10 @@ declare type RequiredVirtualizedSectionListProps< sections: ReadonlyArray> } declare type requireNativeComponent = typeof requireNativeComponent +declare type resolveAssetSource = typeof $$resolveAssetSource +declare function resolveAssetSource_default( + source: ImageSource | null | undefined, +): null | ResolvedAssetSource | undefined declare type ResolvedAssetSource = { readonly height: number | undefined readonly scale: number @@ -5791,7 +5797,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 33fe7464 + Animated, // adba9272 AppConfig, // 35c0ca70 AppRegistry, // edec2a30 AppState, // 12012be5 @@ -5856,10 +5862,10 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a24a5777 - ImageBackground, // 1fbbacef - ImageBackgroundInstance, // 9c900a60 - ImageBackgroundProps, // b078f0ac + Image, // 404e5c9b + ImageBackground, // d2afc8de + ImageBackgroundInstance, // 2e9ed91b + ImageBackgroundProps, // d49fc98c ImageErrorEvent, // b6227714 ImageInstance, // df709524 ImageLoadEvent, // 31709c38 diff --git a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js index 6f40faabb9c1..49a071203245 100644 --- a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js +++ b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js @@ -12,6 +12,7 @@ import type { RootTag, TurboModule, } from '../../../../../Libraries/TurboModule/RCTExport'; +import type {UnsafeObject} from '../../../../../Libraries/Types/CodegenTypes'; import * as TurboModuleRegistry from '../../../../../Libraries/TurboModule/TurboModuleRegistry'; @@ -116,6 +117,9 @@ interface Spec extends TurboModule { surfaceId: RootTag, config: RenderFormatOptions, ) => string; + takeScreenshot(surfaceId: RootTag): UnsafeObject; + registerAsset(uri: string, filePath: string): void; + registerFont(filePath: string): void; reportTestSuiteResultsJSON: (results: string) => void; createShadowNodeReferenceCounter( shadowNode: unknown /* ShadowNode */, diff --git a/private/react-native-fantom/__docs__/README.md b/private/react-native-fantom/__docs__/README.md index 0a6d73d07e5d..58575d183dc1 100644 --- a/private/react-native-fantom/__docs__/README.md +++ b/private/react-native-fantom/__docs__/README.md @@ -99,6 +99,11 @@ values. If another root is still alive and the new metrics differ from the current global dimensions, Fantom applies the new values and emits a warning. Call `root.destroy()` when a root is no longer needed. +Each root also uses its `fontScale` for native text and text-input measurement, +including line heights and wrapping. `allowFontScaling={false}` opts a component +out, and `maxFontSizeMultiplier` caps its scale. Existing roots retain their own +native font scale when a new root changes the global display metrics. + For a full API reference, please see the [inline documentation](../src/index.js) defined for the methods in the `@react-native/fantom` [module](../src/index.js). @@ -119,6 +124,49 @@ Similar to Jest, you can also run Fantom in watch mode using `--watch`: yarn fantom --watch ``` +### PNG screenshots + +`root.takeScreenshot()` captures the currently mounted Fabric revision into an +immutable `Screenshot`. Capture after `runTask` to include its mounted updates; +capture itself does not flush pending React work. The bitmap remains usable +after later updates or root destruction. + +```javascript +const root = Fantom.createRoot(); +Fantom.runTask(() => root.render()); +const screenshot = root.takeScreenshot(); +expect(screenshot).toMatchSnapshot(); +expect(screenshot.isEqual(root.takeScreenshot())).toBe(true); +``` + +The headless renderer paints Yoga's mounted frames with CPU Skia. Text uses the +same layout and bundled fonts as intrinsic measurement, so the measured sizes +and painted content agree. `isEqual()` compares bitmap dimensions and pixels in +memory. + +`toMatchSnapshot()` compares a SHA-256 hash of the bitmap dimensions and pixels +with the value in Jest's `.snap` file. The expected PNG is not read for +comparison. The runner also keeps an inspectable image in +`__snapshots__/.screenshots/`, named from the test, label and +assertion number. Capture defers PNG encoding until an image must be saved; +encoded bytes are cached for repeated saves. A matching hash passes even if the +PNG is missing, and the runner regenerates that image. + +`toMatchInlineSnapshot()` embeds the screenshot's type and hash directly in the +test source. It does not encode or save an image, so inline failures report a +text diff. + +New snapshots are saved during a normal local run. `--updateSnapshot` (`-u`) +accepts changed hashes and PNGs together; CI requires an existing matching hash. +A mismatch leaves the baseline unchanged, saves a difference image alongside the +received image, and reports all three image paths for inspection. Unused images +are removed only during a successful snapshot update with no skipped or filtered +tests. Negated snapshot assertions are unsupported. + +See [Rendering](rendering.md) for the painting architecture, supported features, +font selection and the configuration that keeps shared baselines consistent +across Linux and macOS. + ### Interactive REPL > [!WARNING] @@ -714,3 +762,12 @@ The runner then follows these steps: [React Native Feature Flags](../../../packages/react-native/src/private/featureflags/__docs__/README.md) system, via the `@fantom_flags` pragmas defined in the docblock for test files. + +## Text measurement + +Fantom always measures intrinsic `Text` and `TextInput` sizes so layout tests +exercise wrapping and font scaling without extra setup. Measurement and painting +share Skia and bundled fonts to give tests the same layout on every host. + +See [Rendering](rendering.md) for font selection, font scaling, native build +dependencies and cross-platform raster configuration. diff --git a/private/react-native-fantom/__docs__/rendering.md b/private/react-native-fantom/__docs__/rendering.md new file mode 100644 index 000000000000..a03f3937f512 --- /dev/null +++ b/private/react-native-fantom/__docs__/rendering.md @@ -0,0 +1,153 @@ +# Rendering + +## Text measurement + +Fantom always measures intrinsic `Text` and `TextInput` sizes with Skia's CPU +font metrics. Layout and screenshot painting share the same text layout code, +including fragment styles, constrained-width wrapping, inline attachments, input +values and placeholders. `maximumNumberOfLines` limits the measured box; clipped +attachments retain their entries in Fabric's attachment mapping. + +A root's `fontScale` defaults to `1` and is passed to native layout. Text and +inputs respect `allowFontScaling` and `maxFontSizeMultiplier`; line heights +scale with the font size. `devicePixelRatio` determines raster resolution, +rather than the logical text size. + +### Fonts and assets + +Fantom registers DejaVu Sans regular, bold, italic and bold italic faces from +[`dejavu-fonts-ttf`](https://www.npmjs.com/package/dejavu-fonts-ttf), plus +[`@expo-google-fonts/noto-color-emoji`](https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji). +Both are pinned development dependencies installed by the repository's normal +Yarn setup. Their font licenses are compatible with React Native distribution; +the packages retain the Bitstream Vera/DejaVu and SIL Open Font License notices. +Registration runs once during Fantom's default environment setup, including when +a test has already initialized React Native itself. No host font installation or +Expo runtime is required. + +Metro preserves the source files of bundled assets in Fantom's asset metadata. +The shared resolver selects the asset variant for the root's pixel ratio and +registers its native file path. Ordinary `require()` sources therefore work +without custom path mappings. Keep a fixture alongside the test that uses it. + +Default color emoji selection follows Unicode 16.0 `Emoji_Presentation`. +Explicit text and emoji variation selectors override that default. Selectors and +keycap sequences stay with their bases during fallback and wrapping; HarfBuzz +shapes color emoji sequences for both measurement and painting. General script +shaping, text decorations and ellipsis are not implemented. + +### Public native build + +`yarn workspace @react-native/fantom build` uses Gradle to prepare pinned Skia, +FreeType, libpng, zlib, HarfBuzz and BoringSSL sources. CMake builds HarfBuzz +and BoringSSL as static libraries, and builds Skia with GN and the Android SDK's +Ninja. GN bootstrap uses Python 3. The usual JDK, Android SDK, C/C++ compiler +and Python build tools are required; graphics, crypto and font libraries come +from these prepared sources or bundled assets. + +The native Gradle task tracks C++ sources so edits rebuild the tester. +Dependency versions and download locations are defined in +[`build.gradle.kts`](../build.gradle.kts). Native integration is configured in +[`tester/CMakeLists.txt`](../tester/CMakeLists.txt) and its third-party +subdirectories. + +### Consistent raster configuration + +Tests share snapshot baselines across Linux and macOS. Different font metrics +can change line wrapping and the placement of other views; different raster +rounding can change pixel hashes even when the images look alike. Keeping both +consistent makes a failing snapshot useful evidence of a rendering change, +independent of the machine running the test. + +Both native builds use CPU Skia, FreeType and the same bundled fonts. Host font +managers and platform shaping backends are disabled. Text gamma correction is +configured identically in both builds. + +Fantom disables the legacy shader context and lower precision raster pipeline, +forces the raster pipeline blitter, and selects scalar high precision stages. +Floating-point contraction is disabled with `-ffp-contract=off`. This avoids ARM +and x86 optimized stages producing different rounded channel values when +compositing the same pixels, including color emoji. These settings apply only to +Fantom's Skia library. + +The pinned Skia source adds optional scalar-stage selection and makes the forced +raster pipeline apply to sprite and saved-layer compositing. This keeps native +pixel-format defaults from selecting byte-based alpha rounding instead. Gradle +patches its prepared source copy, and CMake validates the scalar selector +without modifying the source. Keep the internal target's flags and the public GN +flags aligned when changing Skia. + +## Screenshot snapshots + +`root.takeScreenshot()` paints the currently mounted revision into an immutable +CPU bitmap. It does not flush pending React work. Captures survive later commits +and root destruction. Use `isEqual()` to compare their dimensions and pixel rows +in memory. `saveToFile()` writes a PNG, creating parent directories and +replacing an existing file. + +`toMatchSnapshot()` stores a SHA-256 hash of the image dimensions and +premultiplied RGBA pixel rows. It compares that hash with the ordinary Jest +snapshot value; it does not decode an expected PNG. An inspectable PNG is kept +beside the `.snap` file in `.screenshots`. PNG encoding is deferred +until an image must be saved, and PNG bytes are cached for repeated saves. A +matching hash passes even when the standalone PNG is missing; the runner +recreates that image from the matching capture without updating the hash. + +`toMatchInlineSnapshot()` embeds the same `{type: 'Screenshot', hash}` value in +the test source. It compares hashes without encoding or saving PNGs, including +when the inline snapshot is created, updated or fails. Inline failures use +ordinary text snapshot diagnostics. + +Snapshot mismatches retain the received PNG and save a difference PNG in the +same temporary directory. Failure diagnostics include the paths to the expected, +received and difference images, including in CI and redirected output. The +difference highlights changed pixels in red over faded unchanged content; it +does not affect hash comparison. Missing or unreadable images retain their +original diagnostics without a difference image. Snapshot updates copy it into +the baseline directory before cleaning up the temporary file. Unused images are +removed only during a full successful snapshot update; empty, failed, skipped or +filtered suites preserve their baselines. + +Failing screenshot snapshots show expected, received and diff PNG previews in +supported terminals instead of hash diffs, with file paths beneath the images. +Wide terminals show Expected, Received and Difference side by side; narrow +terminals stack them. Fantom selects the image protocol from the detected +terminal. VS Code requires `terminal.integrated.enableImages` in its settings; +Fantom checks the terminal's response before sending images. If that check +cannot confirm support (including when another program is reading terminal +input), it keeps text diagnostics and file paths without printing image headers. +Unsupported terminals, terminal multiplexers, CI and redirected output retain +text diagnostics and file paths. Full text diagnostics also remain when any +required image preview is unavailable. + +GitHub Fantom jobs upload failed comparisons as the `fantom-screenshot-failures` +artifact, grouped by test configuration and snapshot. Each comparison includes +the expected, received and difference PNGs when available. Failures from earlier +retries are retained even if a later attempt passes. + +### Painting + +The painter traverses Fabric's mounted child order, including stacking changes. +Yoga supplies the frames for flow and absolute positioning. Overflow clips +descendants to the view's rounded bounds; each view restores its canvas state +before painting siblings. Nonidentity affine transforms are centered on the +view, while identity transforms preserve child coordinates without extra +floating-point translations. Singular transforms are skipped. + +`View`, styled and nested `Text`, `TextInput`, `Image` and `ScrollView` use this +shared canvas. Text inputs paint values or placeholders, including multiline and +secure entry. Scroll views offset their content to show the current viewport. +Backgrounds, solid borders, rounded corners and opacity follow the mounted +props. + +Images support local PNG files (`file://` or filesystem paths) and base64 PNG +data URIs, with resize modes and tint. Supply network images as local fixtures. +The shared asset resolver maps bundled `require()` sources to native files while +keeping their React Native URIs stable in text snapshots. + +An empty root produces a transparent viewport; a destroyed root cannot be +captured. Viewport offsets do not move the screenshot's origin. The renderer +does not reproduce Android or iOS platform chrome, shadows or perspective +transforms. Additional font fixtures can be registered with +`NativeFantom.registerFont()` before mounting; the default fonts need no +per-test registration. diff --git a/private/react-native-fantom/build.gradle.kts b/private/react-native-fantom/build.gradle.kts index d253b8f14f99..18f201975098 100644 --- a/private/react-native-fantom/build.gradle.kts +++ b/private/react-native-fantom/build.gradle.kts @@ -131,6 +131,113 @@ val prepareNlohmannJson by into("$thirdParty/nlohmann_json") } +val boringsslVersion = "603b54d3d338efde4b84ece7be9aa410e44b1417" +val boringsslArchive = File(downloadsDir, "boringssl-$boringsslVersion.tar.gz") +val downloadBoringSSL by + tasks.registering(Download::class) { + dependsOn(createNativeDepsDirectories) + src("https://github.com/google/boringssl/archive/$boringsslVersion.tar.gz") + onlyIfModified(true) + overwrite(false) + retries(5) + quiet(true) + dest(boringsslArchive) + } +val prepareBoringSSL by + tasks.registering(Copy::class) { + dependsOn(downloadBoringSSL) + from(tarTree(boringsslArchive)) + eachFile { path = path.substringAfter("/") } + includeEmptyDirs = false + into("$thirdParty/boringssl") + } + +// Match the CPU Skia revision used by the native tester in Buck. +val skiaVersion = "3f8fc145ba678f3bd47af50f8fb51bcde474ef9c" + +fun downloadSkiaSource(name: String, url: String, destination: File) = + tasks.register("downloadSkia${name}") { + dependsOn(createNativeDepsDirectories) + src(url) + onlyIfModified(true) + overwrite(false) + retries(5) + quiet(true) + dest(destination) + } + +val skiaArchive = File(downloadsDir, "skia-$skiaVersion.tar.gz") +val downloadSkia = + downloadSkiaSource( + "Core", + "https://github.com/google/skia/archive/$skiaVersion.tar.gz", + skiaArchive, + ) +val prepareSkiaSource by + tasks.registering(Copy::class) { + dependsOn(downloadSkia) + from(tarTree(skiaArchive)) + eachFile { path = path.substringAfter("/") } + filesMatching("**/SkRasterPipeline_opts.h") { + filter { line -> + line.replace( + "#if !defined(__clang__)", + "#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)", + ) + } + } + filesMatching("**/SkBlitter_Sprite.cpp") { + filter { line -> + line + .replace( + " if (gSkForceRasterPipelineBlitter) {", + "#if !defined(SK_FORCE_RASTER_PIPELINE_BLITTER)\n if (gSkForceRasterPipelineBlitter) {", + ) + .replace( + " if (!blitter && !paint.getMaskFilter()) {", + "#endif\n if (!blitter && !paint.getMaskFilter()) {", + ) + } + } + includeEmptyDirs = false + into("$thirdParty/skia") + } + +// GN builds these dependencies into libskia, including FreeType's color PNG glyph support. +val skiaDependencies = + mapOf( + "freetype" to "https://github.com/freetype/freetype/archive/refs/tags/VER-2-14-2.tar.gz", + "harfbuzz" to + "https://chromium.googlesource.com/external/github.com/harfbuzz/harfbuzz.git/+archive/8d1b000a3edc90c12267b836b4ef3f81c0e53edc.tar.gz", + "libpng" to "https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.55.tar.gz", + "zlib" to + "https://chromium.googlesource.com/chromium/src/third_party/zlib/+archive/c876c8f87101c5a75f6014b0f832499afeb65b73.tar.gz", + ) +val prepareSkiaDependencies = + skiaDependencies.map { (name, url) -> + val archive = File(downloadsDir, "skia-$name-${url.substringAfterLast('/')}") + val download = downloadSkiaSource(name.replaceFirstChar { it.uppercase() }, url, archive) + tasks.register("prepareSkia${name.replaceFirstChar { it.uppercase() }}") { + dependsOn(download, prepareSkiaSource) + from(tarTree(archive)) + if (name == "freetype" || name == "libpng") { + eachFile { path = path.substringAfter("/") } + } + if (name == "zlib") { + filesMatching("zutil.h") { + filter { line -> + line.replace( + "defined(TARGET_OS_MAC)", + "(defined(TARGET_OS_MAC) && !defined(__APPLE__))", + ) + } + } + } + includeEmptyDirs = false + into("$thirdParty/skia/third_party/externals/$name") + } + } + var codegenSrcDir = File("$reactAndroidBuildDir/generated/source/codegen/jni") var codegenOutDir = File("$buildDir/codegen") val prepareRNCodegen by @@ -166,6 +273,9 @@ val prepareNative3pDependencies by prepareGflags, prepareNlohmannJson, prepareFolly, + prepareBoringSSL, + prepareSkiaSource, + *prepareSkiaDependencies.toTypedArray(), ":packages:react-native:ReactAndroid:prepareBoost", ":packages:react-native:ReactAndroid:prepareDoubleConversion", ":packages:react-native:ReactAndroid:prepareFastFloat", @@ -197,6 +307,7 @@ val configureFantomTester by "-DCMAKE_BUILD_TYPE=Debug", "-DFANTOM_CODEGEN_DIR=$buildDir/codegen", "-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party", + "-DFANTOM_NINJA=$cmakePath/bin/ninja", "-DREACT_ANDROID_DIR=$reactAndroidDir", "-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon", "-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform", @@ -215,6 +326,7 @@ val buildFantomTester by tasks.registering(CustomExecTask::class) { dependsOn(configureFantomTester) workingDir(testerDir) + inputs.dir(testerDir) inputs.files(testerBuildOutputFileTree) commandLine( cmakeBinaryPath, diff --git a/private/react-native-fantom/config/assetPlugin.js b/private/react-native-fantom/config/assetPlugin.js new file mode 100644 index 000000000000..65c13bed15d0 --- /dev/null +++ b/private/react-native-fantom/config/assetPlugin.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/*:: +import type {AssetData} from 'metro/private/Assets'; +*/ + +module.exports = function assetPlugin /*:: */( + asset /*: T */, +) /*: {...T, fantomFiles: Array, ...} */ { + return {...asset, fantomFiles: asset.files}; +}; diff --git a/private/react-native-fantom/config/jest.config.js b/private/react-native-fantom/config/jest.config.js index a5af1600422d..9338ac15f11a 100644 --- a/private/react-native-fantom/config/jest.config.js +++ b/private/react-native-fantom/config/jest.config.js @@ -54,6 +54,7 @@ module.exports = { testPathIgnorePatterns: baseConfig.testPathIgnorePatterns, transformIgnorePatterns: ['.*'], testRunner: '/private/react-native-fantom/runner/index.js', + reporters: ['/private/react-native-fantom/reporter/index.js'], watchPathIgnorePatterns: ['/private/react-native-fantom/build/'], globalSetup: '/private/react-native-fantom/runner/global-setup/setup.js', diff --git a/private/react-native-fantom/config/metro-babel-transformer.flow.js b/private/react-native-fantom/config/metro-babel-transformer.flow.js index 1b141d0707b1..cb86d07f3710 100644 --- a/private/react-native-fantom/config/metro-babel-transformer.flow.js +++ b/private/react-native-fantom/config/metro-babel-transformer.flow.js @@ -57,6 +57,7 @@ module.exports = { const cacheKeyParts = [ MetroBabelTransformer.getCacheKey?.() ?? '', fs.readFileSync(__filename), + fs.readFileSync(require.resolve('./assetPlugin')), fs.readFileSync( require.resolve('./babel-plugins/inject-debugger-statements-in-tests'), ), diff --git a/private/react-native-fantom/config/metro.config.js b/private/react-native-fantom/config/metro.config.js index e6398adb7212..87a0a4a9f21a 100644 --- a/private/react-native-fantom/config/metro.config.js +++ b/private/react-native-fantom/config/metro.config.js @@ -55,6 +55,7 @@ const config /*: InputConfigT */ = { : null, }, transformer: { + assetPlugins: [require.resolve('./assetPlugin')], // We need to wrap the default transformer so we can run it from source // using babel-register. babelTransformerPath: path.resolve(__dirname, 'metro-babel-transformer.js'), diff --git a/private/react-native-fantom/package.json b/private/react-native-fantom/package.json index f0f965fbba23..0f8b2c57d713 100644 --- a/private/react-native-fantom/package.json +++ b/private/react-native-fantom/package.json @@ -8,7 +8,12 @@ "build": "./build.sh" }, "dependencies": { - "babel-plugin-istanbul": "^7.0.0" + "babel-plugin-istanbul": "^7.0.0", + "pngjs": "7.0.0" + }, + "devDependencies": { + "@expo-google-fonts/noto-color-emoji": "0.4.7", + "dejavu-fonts-ttf": "2.37.3" }, "peerDependencies": { "jest": "^29.7.0", diff --git a/private/react-native-fantom/reporter/ScreenshotReporter.js b/private/react-native-fantom/reporter/ScreenshotReporter.js new file mode 100644 index 000000000000..90ee783d302f --- /dev/null +++ b/private/react-native-fantom/reporter/ScreenshotReporter.js @@ -0,0 +1,255 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotResult} from '../runtime/ScreenshotContext'; +import type {TerminalEnvironment} from './terminalImages'; + +import createImageRow from './imageRow'; +import saveScreenshotArtifacts from './screenshotArtifacts'; +import queryImageSupport from './terminalCapabilities'; +import {encodeTerminalImage, getImageProtocol} from './terminalImages'; +import { + DefaultReporter, + SummaryReporter, + VerboseReporter, +} from '@jest/reporters'; +import fs from 'node:fs'; +import path from 'node:path'; +import tty from 'node:tty'; + +export type TestResult = { + readonly failureMessage?: ?string, + readonly screenshotFailureMessage?: ?string, + readonly testResults: ReadonlyArray< + Readonly<{ + fullName: string, + status: string, + screenshotResults?: ReadonlyArray, + }>, + >, +}; + +type AggregatedResult = { + readonly testResults: ReadonlyArray, + ... +}; + +interface TerminalOutput { + readonly isTTY?: boolean; + readonly columns?: number; + readonly rows?: number; + write(string): unknown; +} + +export default function createScreenshotReporter(globalConfig: { + readonly verbose?: boolean, + ... +}): DefaultReporter { + const Reporter: Class> = + globalConfig.verbose === true ? VerboseReporter : DefaultReporter; + const summaryReporter = new SummaryReporter(globalConfig); + let terminalResults = new WeakMap(); + return new (class ScreenshotReporter extends Reporter { + #vsCodeImages: ?Promise = null; + + onRunStart(aggregatedResults: unknown, options: unknown): void { + this.#vsCodeImages = null; + terminalResults = new WeakMap(); + super.onRunStart(aggregatedResults, options); + summaryReporter.onRunStart(aggregatedResults, options); + } + + async onTestResult( + test: unknown, + result: TestResult, + aggregatedResults: AggregatedResult, + ): Promise { + if (process.env.GITHUB_ACTIONS === 'true') { + const errors = saveScreenshotArtifacts( + result.testResults + .filter(testResult => testResult.status === 'failed') + .flatMap(testResult => testResult.screenshotResults ?? []), + path.resolve(__dirname, '../build/screenshot-failures'), + ); + for (const error of errors) { + console.warn(error); + } + } + const input = process.stdin; + const output = process.stderr; + let terminalResult = result; + if (output instanceof tty.WriteStream) { + let supportsVSCodeImages = false; + if ( + process.env.TERM_PROGRAM === 'vscode' && + input instanceof tty.ReadStream && + getImageProtocol(output.isTTY, process.env, true) != null && + result.testResults.some( + testResult => + testResult.status === 'failed' && + testResult.screenshotResults?.some( + screenshot => + !screenshot.pass && screenshot.receivedPath != null, + ), + ) + ) { + this.#vsCodeImages ??= queryImageSupport(input, output); + supportsVSCodeImages = await this.#vsCodeImages; + } + terminalResult = reportScreenshotPreviews( + result, + output, + process.env, + supportsVSCodeImages, + ); + } + terminalResults.set(result, terminalResult); + await super.onTestResult(test, terminalResult, aggregatedResults); + } + + onRunComplete( + testContexts: unknown, + aggregatedResults: AggregatedResult, + ): void { + super.onRunComplete(testContexts, aggregatedResults); + summaryReporter.onRunComplete(testContexts, { + ...aggregatedResults, + testResults: aggregatedResults.testResults.map( + result => terminalResults.get(result) ?? result, + ), + }); + } + })(globalConfig); +} + +export function reportScreenshotPreviews( + result: TestResult, + output: TerminalOutput, + env: TerminalEnvironment, + supportsVSCodeImages: boolean = false, +): TestResult { + const protocol = getImageProtocol(output.isTTY, env, supportsVSCodeImages); + if (protocol == null) { + return result; + } + const readErrors = new Map(); + const readImage = (file: string): ?Buffer => { + try { + return fs.readFileSync(file); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + readErrors.set(file, message.replace(/\p{Cc}/gu, '')); + return null; + } + }; + const columns = + output.columns != null && output.columns > 0 ? output.columns : 80; + const rows = output.rows != null && output.rows > 0 ? output.rows : 30; + const horizontal = columns >= 60; + const preview = (png: Buffer) => + encodeTerminalImage(png, protocol, { + columns: columns - 2, + rows: rows - 9, + tmux: env.TMUX != null, + }); + let previews = ''; + let hasPreview = false; + let allPreviewsRendered = true; + for (const test of result.testResults) { + if (test.status !== 'failed') { + continue; + } + for (const screenshot of test.screenshotResults ?? []) { + const receivedPath = screenshot.receivedPath; + if (screenshot.pass || receivedPath == null) { + continue; + } + const panels = [ + { + label: 'Expected', + path: screenshot.expectedPath, + image: screenshot.isNew ? null : readImage(screenshot.expectedPath), + unavailable: screenshot.isNew ? 'no baseline' : 'preview unavailable', + required: !screenshot.isNew, + }, + { + label: 'Received', + path: receivedPath, + image: readImage(receivedPath), + unavailable: 'preview unavailable', + required: true, + }, + { + label: 'Difference', + path: screenshot.differencePath, + image: + screenshot.differencePath == null + ? null + : readImage(screenshot.differencePath), + unavailable: screenshot.isNew ? 'no baseline' : 'preview unavailable', + required: !screenshot.isNew, + }, + ]; + const title = test.fullName.replace(/\p{Cc}/gu, ''); + previews += `\nScreenshot snapshot: ${title}\n`; + const images = panels + .map(panel => panel.image) + .filter(image => image != null); + const row = + horizontal && images.length === panels.length + ? createImageRow(images) + : null; + const rowPreview = row == null ? null : preview(row); + if (rowPreview != null) { + hasPreview = true; + const labelWidth = Math.max( + 8, + Math.floor((rowPreview.columns - 6) / 3), + ); + previews += + panels.map(panel => panel.label.padEnd(labelWidth)).join(' | ') + + '\n'; + previews += rowPreview.sequence + '\n'; + for (const panel of panels) { + previews += `${panel.label}: ${panel.path ?? panel.unavailable}\n`; + } + } else { + for (const panel of panels) { + previews += `${panel.label}: ${panel.path ?? panel.unavailable}\n`; + const image = panel.image == null ? null : preview(panel.image); + hasPreview ||= image != null; + allPreviewsRendered &&= image != null || !panel.required; + const readError = readErrors.get(panel.path ?? ''); + previews += + (image?.sequence ?? + (readError == null + ? panel.unavailable + : `preview unavailable: ${readError}`)) + '\n'; + } + } + } + } + if (previews === '') { + return result; + } + try { + output.write(previews); + } catch { + return result; + } + if ( + hasPreview && + allPreviewsRendered && + result.screenshotFailureMessage != null + ) { + return {...result, failureMessage: result.screenshotFailureMessage}; + } + return result; +} diff --git a/private/react-native-fantom/reporter/__tests__/ScreenshotReporter-test.js b/private/react-native-fantom/reporter/__tests__/ScreenshotReporter-test.js new file mode 100644 index 000000000000..f796539595a1 --- /dev/null +++ b/private/react-native-fantom/reporter/__tests__/ScreenshotReporter-test.js @@ -0,0 +1,632 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotResult} from '../../runtime/ScreenshotContext'; +import type {TestResult} from '../ScreenshotReporter'; + +import createImageRow from '../imageRow'; +import createScreenshotReporter, { + reportScreenshotPreviews, +} from '../ScreenshotReporter'; +import {encodeTerminalImage, getImageProtocol} from '../terminalImages'; +import { + DefaultReporter, + SummaryReporter, + VerboseReporter, +} from '@jest/reporters'; +import {Buffer} from 'node:buffer'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import tty from 'node:tty'; +import {PNG} from 'pngjs/lib/png'; + +jest.mock('@jest/reporters', () => { + class MockDefaultReporter { + onRunStart() {} + onTestResult() {} + onRunComplete() {} + } + return { + DefaultReporter: MockDefaultReporter, + SummaryReporter: class MockSummaryReporter { + onRunStart() {} + onRunComplete() {} + }, + VerboseReporter: class MockVerboseReporter extends MockDefaultReporter {}, + }; +}); + +const fixture = path.join(__dirname, 'fixtures', 'preview.png'); +const png = fs.readFileSync(fixture); +const output = jest.fn<[string], boolean>(() => true); +let directory: string; +let differencePath: string; +let env: {[string]: string | void}; +let savedEnvironment: typeof process.env; +const terminal = {isTTY: true, columns: 80, rows: 30, write: output}; + +beforeEach(() => { + savedEnvironment = process.env; + terminal.columns = 80; + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fantom-preview-test-')); + differencePath = path.join(directory, 'image.diff.png'); + fs.writeFileSync( + differencePath, + PNG.sync.write(new PNG({width: 64, height: 128})), + ); + env = {TERM_PROGRAM: 'iTerm.app'}; +}); + +afterEach(() => { + output.mockClear(); + jest.restoreAllMocks(); + process.env = savedEnvironment; + fs.rmSync(directory, {recursive: true, force: true}); +}); + +function report( + changes: Partial = {}, + status: string = 'failed', + supportsVSCodeImages: boolean = false, +): TestResult { + const result: TestResult = { + failureMessage: 'Expected old-hash, received new-hash', + screenshotFailureMessage: 'Expected to match screenshot snapshot.', + testResults: [ + { + fullName: 'Example renders', + status, + screenshotResults: [ + { + fileName: 'image.png', + expectedPath: fixture, + receivedPath: fixture, + differencePath: changes.isNew === true ? undefined : differencePath, + pass: false, + isNew: false, + ...changes, + }, + ], + }, + ], + }; + Object.freeze(result); + const terminalResult = reportScreenshotPreviews( + result, + terminal, + env, + supportsVSCodeImages, + ); + expect(result.failureMessage).toContain('old-hash'); + return terminalResult; +} + +it('detects iTerm2, WezTerm and Kitty', () => { + for (const TERM_PROGRAM of ['iTerm.app', 'WezTerm']) { + expect(getImageProtocol(true, {TERM_PROGRAM})).toBe('iterm2'); + } + expect(getImageProtocol(true, {LC_TERMINAL: 'iTerm2'})).toBe('iterm2'); + expect(getImageProtocol(true, {TERM: 'xterm-kitty'})).toBe('kitty'); + expect(getImageProtocol(true, {KITTY_WINDOW_ID: '1'})).toBe('kitty'); +}); + +it('shows previews in VS Code only with confirmed image support', () => { + env = {TERM_PROGRAM: 'vscode', LC_TERMINAL: 'iTerm2'}; + expect(getImageProtocol(true, env)).toBe(null); + expect(getImageProtocol(true, env, true)).toBe('iterm2'); + report({}, 'failed', true); + expect(output).toHaveBeenCalledTimes(1); +}); + +it('keeps the image row header out of VS Code without confirmed image support', () => { + env = {TERM_PROGRAM: 'vscode'}; + const result = report(); + expect(output).not.toHaveBeenCalled(); + expect(result.failureMessage).toContain('old-hash'); +}); + +it('keeps text diagnostics in terminal multiplexers', () => { + for (const multiplexer of [{TMUX: '/tmp/tmux'}, {STY: 'screen-session'}]) { + env = {TERM_PROGRAM: 'iTerm.app', ...multiplexer}; + expect(getImageProtocol(true, env)).toBe(null); + report(); + expect(output).not.toHaveBeenCalled(); + } +}); + +it('uses the detected terminal identity over SSH', () => { + expect( + getImageProtocol(true, {SSH_TTY: '/dev/pts/0', TERM_PROGRAM: 'iTerm.app'}), + ).toBe('iterm2'); + expect( + getImageProtocol(true, {SSH_TTY: '/dev/pts/0', TERM: 'xterm-kitty'}), + ).toBe('kitty'); +}); + +it('keeps image sequences out of CI, pipes and unsupported terminals', () => { + expect(getImageProtocol(false, {TERM_PROGRAM: 'vscode'})).toBe(null); + expect(getImageProtocol(true, {TERM: 'xterm-256color'})).toBe(null); + expect(getImageProtocol(true, {CI: '1', TERM_PROGRAM: 'iTerm.app'})).toBe( + null, + ); + env.CI = 'true'; + report(); + expect(output).not.toHaveBeenCalled(); +}); + +it('shows saved expected, received and difference PNGs in one row', () => { + report(); + const text = output.mock.calls[0][0]; + expect(text).toContain('Screenshot snapshot: Example renders'); + expect(text).toMatch(/Expected +\| Received +\| Difference/); + expect(text).toContain(`Expected: ${fixture}`); + expect(text).toContain(`Received: ${fixture}`); + expect(text).toContain(`Difference: ${differencePath}`); + const images = [...text.matchAll(/preserveAspectRatio=1:([A-Za-z0-9+/=]+)/g)]; + expect(images.length).toBe(1); + const row = PNG.sync.read(Buffer.from(images[0][1], 'base64')); + const baseline = PNG.sync.read(png); + const difference = PNG.sync.read(fs.readFileSync(differencePath)); + expect(row.width).toBe(224); + expect(row.height).toBe(128); + for (const [panel, image] of [baseline, baseline, difference].entries()) { + for (let y = 0; y < image.height; y++) { + const offset = (y * row.width + panel * 80) * 4; + expect( + row.data + .subarray(offset, offset + image.width * 4) + .equals( + image.data.subarray(y * image.width * 4, (y + 1) * image.width * 4), + ), + ).toBe(true); + } + } + expect(text).not.toContain('\x1b7'); +}); + +it('removes terminal control characters from test names while preserving Unicode text', () => { + const result = report(); + output.mockClear(); + reportScreenshotPreviews( + { + ...result, + testResults: result.testResults.map(test => ({ + ...test, + fullName: '\x00\x1b[31mPreview\n\x7f\u0085 😀', + })), + }, + terminal, + env, + ); + expect(output.mock.calls[0][0]).toContain( + 'Screenshot snapshot: [31mPreview 😀', + ); + expect(output.mock.calls[0][0]).not.toContain('\x1b[31m'); +}); + +it('chunks Kitty transfers and preserves the complete PNG', () => { + const text = + encodeTerminalImage(png, 'kitty', {columns: 78, rows: 25, tmux: false}) + ?.sequence ?? ''; + const chunks = text + .split('\x1b_G') + .slice(1) + .map(packet => { + expect(packet.endsWith('\x1b\\')).toBe(true); + return packet.slice(0, -2).split(';'); + }); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks[0][0]).toContain('a=T,f=100,t=d,q=2,'); + expect(chunks[chunks.length - 1][0]).toBe('m=0'); + for (const [index, chunk] of chunks.entries()) { + expect(chunk[1].length).toBeLessThanOrEqual(4096); + if (index !== chunks.length - 1) { + expect(chunk[0]).toContain('m=1'); + } + } + expect( + Buffer.from(chunks.map(chunk => chunk[1]).join(''), 'base64').equals(png), + ).toBe(true); +}); + +it('bounds previews to the terminal and does not enlarge small images', () => { + const small = + encodeTerminalImage(png, 'kitty', {columns: 78, rows: 25, tmux: false}) + ?.sequence ?? ''; + expect(small).toContain('c=8,m='); + const narrow = + encodeTerminalImage(png, 'kitty', {columns: 4, rows: 25, tmux: false}) + ?.sequence ?? ''; + expect(narrow).toContain('c=4,m='); + const short = + encodeTerminalImage(png, 'kitty', {columns: 78, rows: 3, tmux: false}) + ?.sequence ?? ''; + expect(short).toContain('c=3,m='); + const tiny = + encodeTerminalImage(png, 'kitty', {columns: 1, rows: 1, tmux: false}) + ?.sequence ?? ''; + expect(tiny).toContain('c=1,m='); +}); + +it('wraps graphics for tmux passthrough', () => { + for (const protocol of ['iterm2', 'kitty'] as const) { + const direct = + encodeTerminalImage(png, protocol, {columns: 78, rows: 25, tmux: false}) + ?.sequence ?? ''; + const wrapped = + encodeTerminalImage(png, protocol, {columns: 78, rows: 25, tmux: true}) + ?.sequence ?? ''; + const packets = wrapped.split('\x1bPtmux;').slice(1); + expect( + packets + .map(packet => { + expect(packet.endsWith('\x1b\\')).toBe(true); + return packet.slice(0, -2).replaceAll('\x1b\x1b', '\x1b'); + }) + .join(''), + ).toBe(direct); + } +}); + +it('shows a received preview when no baseline exists', () => { + const result = report({ + isNew: true, + expectedPath: '/nonexistent/baseline.png', + }); + const text = output.mock.calls[0][0]; + expect(text).toContain('no baseline'); + expect(text).toContain(`Expected: /nonexistent/baseline.png`); + expect(text).toContain(`Received: ${fixture}`); + expect(text).toContain('Difference: no baseline'); + expect(text.split('\x1b]1337;').length - 1).toBe(1); + expect(result.failureMessage).toBe(result.screenshotFailureMessage); +}); + +it('isolates missing previews and continues with other images', () => { + report({expectedPath: '/nonexistent/baseline.png'}); + expect(output.mock.calls[0][0]).toContain('preview unavailable'); + expect(output.mock.calls[0][0]).toContain( + 'Expected: /nonexistent/baseline.png', + ); + expect(output.mock.calls[0][0]).toContain(`Received: ${fixture}\n\x1b]1337;`); + expect(output.mock.calls[0][0]).toContain(`Difference: ${differencePath}`); + output.mockClear(); + report({receivedPath: '/nonexistent/received.png'}); + expect(output.mock.calls[0][0]).toContain(`Expected: ${fixture}\n\x1b]1337;`); + expect(output.mock.calls[0][0]).toContain( + 'Received: /nonexistent/received.png', + ); +}); + +it('reports file read errors while continuing with readable previews', () => { + report({expectedPath: directory}); + const text = output.mock.calls[0][0]; + expect(text).toContain(`Expected: ${directory}`); + expect(text).toContain('EISDIR'); + expect(text).toContain(`Received: ${fixture}\n\x1b]1337;`); + expect(text).toContain(`Difference: ${differencePath}`); +}); + +it('retains full failure diagnostics when none of the previews render', () => { + const missing = path.join(directory, 'missing.png'); + const result: TestResult = { + failureMessage: 'Expected old-hash, received new-hash', + screenshotFailureMessage: 'Expected to match screenshot snapshot.', + testResults: [ + { + fullName: 'Example renders', + status: 'failed', + screenshotResults: [ + { + fileName: 'image.png', + expectedPath: missing, + receivedPath: missing, + differencePath: missing, + pass: false, + isNew: false, + }, + ], + }, + ], + }; + const terminalResult = reportScreenshotPreviews(result, terminal, env); + expect(terminalResult).toBe(result); + expect(output.mock.calls[0][0]).not.toContain('\x1b]1337;'); + expect(result.failureMessage).toContain('old-hash'); +}); + +it('retains full failure diagnostics when a required preview is unavailable', () => { + const missing = path.join(directory, 'missing.png'); + for (const changes of [ + {expectedPath: missing}, + {receivedPath: missing}, + {differencePath: missing}, + {differencePath: undefined}, + ]) { + const result = report(changes); + const text = output.mock.calls[output.mock.calls.length - 1][0]; + expect(text.split('\x1b]1337;').length - 1).toBe(2); + expect(result.failureMessage).toContain('old-hash'); + } +}); + +it('retains diagnostics for all failures when another screenshot has a partial preview', () => { + const screenshot: ScreenshotResult = { + fileName: 'image.png', + expectedPath: fixture, + receivedPath: fixture, + differencePath, + pass: false, + isNew: false, + }; + const result: TestResult = { + failureMessage: 'Expected old-hash, received new-hash', + screenshotFailureMessage: 'Expected to match screenshot snapshot.', + testResults: [ + { + fullName: 'Complete preview', + status: 'failed', + screenshotResults: [screenshot], + }, + { + fullName: 'Partial preview', + status: 'failed', + screenshotResults: [ + {...screenshot, expectedPath: path.join(directory, 'missing.png')}, + ], + }, + ], + }; + reportScreenshotPreviews(result, terminal, env); + const text = output.mock.calls[0][0]; + expect(text).toContain('Complete preview'); + expect(text).toContain('Partial preview'); + expect(result.failureMessage).toContain('old-hash'); +}); + +it('ignores passing, skipped, accepted and non-screenshot assertions', () => { + report({pass: true}); + report({}, 'pending'); + report({}, 'passed'); + report({receivedPath: null}); + reportScreenshotPreviews( + { + testResults: [{fullName: 'Ordinary test', status: 'failed'}], + }, + terminal, + env, + ); + expect(output).not.toHaveBeenCalled(); +}); + +it('ignores unavailable or invalid PNG previews without changing failures', () => { + report({ + expectedPath: '/nonexistent/baseline.png', + receivedPath: '/nonexistent/received.png', + differencePath: '/nonexistent/difference.png', + }); + expect(output.mock.calls[0][0]).toContain( + 'Expected: /nonexistent/baseline.png', + ); + expect(output.mock.calls[0][0]).toContain( + 'Received: /nonexistent/received.png', + ); + expect(output.mock.calls[0][0]).not.toContain('\x1b]1337;'); + expect( + encodeTerminalImage(Buffer.from('not a PNG'), 'iterm2', { + columns: 78, + rows: 25, + tmux: false, + }), + ).toBe(null); +}); + +it('keeps the original result after successful previews and on output failures', () => { + const result: TestResult = { + failureMessage: 'Expected old-hash, received new-hash', + screenshotFailureMessage: 'Expected to match screenshot snapshot.', + testResults: [ + { + fullName: 'Example renders', + status: 'failed', + screenshotResults: [ + { + fileName: 'image.png', + expectedPath: fixture, + receivedPath: fixture, + differencePath, + pass: false, + isNew: false, + }, + ], + }, + ], + }; + reportScreenshotPreviews(result, {...terminal, isTTY: false}, env); + expect(output).not.toHaveBeenCalled(); + expect(result.failureMessage).toContain('old-hash'); + reportScreenshotPreviews(result, terminal, { + TERM: 'xterm-256color', + }); + expect(output).not.toHaveBeenCalled(); + expect(result.failureMessage).toContain('old-hash'); + reportScreenshotPreviews( + result, + { + ...terminal, + write: () => { + throw new Error('closed output'); + }, + }, + env, + ); + expect(result.failureMessage).toContain('old-hash'); + Object.freeze(result); + const terminalResult = reportScreenshotPreviews(result, terminal, env); + expect(output).toHaveBeenCalledTimes(1); + expect(output.mock.calls[0][0]).toContain(`Difference: ${differencePath}`); + expect(terminalResult).not.toBe(result); + expect(terminalResult.failureMessage).toBe(result.screenshotFailureMessage); + expect(terminalResult.testResults).toBe(result.testResults); + expect(result.failureMessage).toContain('old-hash'); +}); + +it('stacks previews in a narrow terminal and prints all paths', () => { + terminal.columns = 40; + report(); + const text = output.mock.calls[0][0]; + expect(text).not.toContain(' | '); + expect(text).toContain(`Expected: ${fixture}\n\x1b]1337;`); + expect(text).toContain(`Received: ${fixture}\n\x1b]1337;`); + expect(text).toContain(`Difference: ${differencePath}\n\x1b]1337;`); +}); + +it('falls back when a preview row cannot be decoded or is too large', () => { + expect(createImageRow([png, Buffer.from('invalid')])).toBe(null); + const corrupt = Buffer.from(png); + corrupt[corrupt.length - 5] = (corrupt[corrupt.length - 5] + 1) % 256; + expect(createImageRow([png, corrupt])).toBe(null); + const oversized = Buffer.from(png); + oversized.writeUInt32BE(4096, 16); + oversized.writeUInt32BE(4096, 20); + expect(createImageRow([oversized, oversized, oversized])).toBe(null); +}); + +it('preserves different image dimensions and pads the preview panels', () => { + const images = [ + new PNG({width: 2, height: 3}), + new PNG({width: 1, height: 4}), + new PNG({width: 3, height: 1}), + ]; + images[0].data.fill(255); + images[1].data.fill(127); + images[2].data.fill(63); + const row = PNG.sync.read( + createImageRow(images.map(image => PNG.sync.write(image))) ?? + Buffer.alloc(0), + ); + const pixel = (x: number, y: number) => [ + ...row.data.subarray((y * row.width + x) * 4, (y * row.width + x + 1) * 4), + ]; + expect(row.width).toBe(41); + expect(row.height).toBe(4); + expect(pixel(0, 0)).toEqual([255, 255, 255, 255]); + expect(pixel(1, 2)).toEqual([255, 255, 255, 255]); + expect(pixel(2, 0)).toEqual([0, 0, 0, 0]); + expect(pixel(0, 3)).toEqual([0, 0, 0, 0]); + expect(pixel(3, 0)).toEqual([0, 0, 0, 0]); + expect(pixel(19, 3)).toEqual([127, 127, 127, 127]); + expect(pixel(20, 0)).toEqual([0, 0, 0, 0]); + expect(pixel(38, 0)).toEqual([63, 63, 63, 63]); + expect(pixel(40, 0)).toEqual([63, 63, 63, 63]); + expect(pixel(38, 1)).toEqual([0, 0, 0, 0]); +}); + +it('rejects PNGs with conflicting dimension headers', () => { + const small = PNG.sync.write(new PNG({width: 1, height: 1})); + const wide = PNG.sync.write(new PNG({width: 10, height: 1})); + const conflicting = Buffer.concat([small.subarray(0, 33), wide.subarray(8)]); + expect(createImageRow([conflicting, small])).toBe(null); +}); + +for (const verbose of [false, true]) { + it(`preserves Jest reporter hooks with verbose=${String(verbose)}`, async () => { + const start = jest.spyOn(DefaultReporter.prototype, 'onRunStart'); + const finish = jest.spyOn(DefaultReporter.prototype, 'onTestResult'); + const summaryStart = jest.spyOn(SummaryReporter.prototype, 'onRunStart'); + const reporter = createScreenshotReporter({verbose}); + expect(reporter).toBeInstanceOf( + verbose ? VerboseReporter : DefaultReporter, + ); + const aggregatedResults = {testResults: [] as Array}; + const options = {}; + reporter.onRunStart(aggregatedResults, options); + expect(start).toHaveBeenCalledWith(aggregatedResults, options); + expect(summaryStart).toHaveBeenCalledWith(aggregatedResults, options); + const test = {}; + const result = Object.freeze({testResults: []}); + await reporter.onTestResult(test, result, aggregatedResults); + expect(finish).toHaveBeenCalledWith(test, result, aggregatedResults); + }); +} + +it('uses separate terminal results for the default reporter and final summary', async () => { + process.env = {...process.env, TERM_PROGRAM: 'iTerm.app'}; + delete process.env.CI; + delete process.env.TMUX; + delete process.env.STY; + jest.spyOn(process, 'stderr', 'get').mockReturnValue( + Object.create(tty.WriteStream.prototype, { + isTTY: {value: true}, + columns: {value: terminal.columns}, + rows: {value: terminal.rows}, + write: {value: output}, + }), + ); + const finish = jest.spyOn(DefaultReporter.prototype, 'onTestResult'); + const complete = jest.spyOn(DefaultReporter.prototype, 'onRunComplete'); + const summary = jest.spyOn(SummaryReporter.prototype, 'onRunComplete'); + const reporter = createScreenshotReporter({}); + const result = Object.freeze({ + ...report(), + failureMessage: 'Expected old-hash, received new-hash', + }); + const testResults = Object.freeze([result]); + const aggregatedResults = Object.freeze({testResults, numFailedTests: 1}); + const test = {}; + await reporter.onTestResult(test, result, aggregatedResults); + const terminalResult = finish.mock.calls[0][1]; + expect(terminalResult).not.toBe(result); + expect(terminalResult).toEqual({ + ...result, + failureMessage: result.screenshotFailureMessage, + }); + const testContexts = new Set(); + reporter.onRunComplete(testContexts, aggregatedResults); + expect(complete).toHaveBeenCalledTimes(1); + expect(summary).toHaveBeenCalledWith(testContexts, { + ...aggregatedResults, + testResults: [terminalResult], + }); + expect(result.failureMessage).toContain('old-hash'); + expect(aggregatedResults.testResults).toBe(testResults); +}); + +it('collects failed screenshots in GitHub jobs without changing their results', async () => { + process.env = {...process.env, GITHUB_ACTIONS: 'true', CI: 'true'}; + const save = jest + .spyOn(require('../screenshotArtifacts'), 'default') + .mockReturnValue([]); + const screenshot: ScreenshotResult = { + fileName: 'image.png', + expectedPath: fixture, + receivedPath: fixture, + differencePath, + pass: false, + isNew: false, + }; + const result = Object.freeze({ + failureMessage: 'Expected old-hash, received new-hash', + testResults: [ + {fullName: 'fails', status: 'failed', screenshotResults: [screenshot]}, + {fullName: 'passes', status: 'passed', screenshotResults: [screenshot]}, + ], + }); + const finish = jest.spyOn(DefaultReporter.prototype, 'onTestResult'); + const reporter = createScreenshotReporter({}); + await reporter.onTestResult({}, result, {testResults: [result]}); + expect(save).toHaveBeenCalledWith( + [screenshot], + path.resolve(__dirname, '../../build/screenshot-failures'), + ); + expect(finish.mock.calls[0][1]).toBe(result); + expect(result.failureMessage).toContain('old-hash'); +}); diff --git a/private/react-native-fantom/reporter/__tests__/fixtures/preview.png b/private/react-native-fantom/reporter/__tests__/fixtures/preview.png new file mode 100644 index 000000000000..30eda8cb06af Binary files /dev/null and b/private/react-native-fantom/reporter/__tests__/fixtures/preview.png differ diff --git a/private/react-native-fantom/reporter/__tests__/screenshotArtifacts-test.js b/private/react-native-fantom/reporter/__tests__/screenshotArtifacts-test.js new file mode 100644 index 000000000000..25c91e89b4ed --- /dev/null +++ b/private/react-native-fantom/reporter/__tests__/screenshotArtifacts-test.js @@ -0,0 +1,168 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotResult} from '../../runtime/ScreenshotContext'; + +import saveScreenshotArtifacts from '../screenshotArtifacts'; +import {Buffer} from 'node:buffer'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +let directory: string; +let artifacts: string; +let result: { + ...ScreenshotResult, + receivedPath: string, + differencePath: string, +}; +const expected = fs.readFileSync(path.join(__dirname, 'fixtures/preview.png')); +const received = Buffer.concat([expected, Buffer.from('received')]); +const difference = Buffer.concat([expected, Buffer.from('difference')]); + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fantom-artifacts-test-')); + artifacts = path.join(directory, 'artifacts'); + const captureDirectory = path.join(directory, 'attempt-1'); + fs.mkdirSync(captureDirectory); + result = { + fileName: 'image.png', + expectedPath: path.join(directory, 'expected.png'), + receivedPath: path.join(captureDirectory, 'image.png'), + differencePath: path.join(captureDirectory, 'image.diff.png'), + pass: false, + isNew: false, + }; + fs.writeFileSync(result.expectedPath, expected); + fs.writeFileSync(result.receivedPath, received); + fs.writeFileSync(result.differencePath, difference); +}); + +afterEach(() => { + fs.rmSync(directory, {recursive: true, force: true}); +}); + +function getComparisonDirectory(attempt: string = 'attempt-1'): string { + const attemptDirectory = path.join(artifacts, attempt); + const comparisons = fs.readdirSync(attemptDirectory); + expect(comparisons.length).toBe(1); + return path.join(attemptDirectory, comparisons[0]); +} + +it('copies expected, received and difference images without modifying the sources', () => { + expect(saveScreenshotArtifacts([result], artifacts)).toEqual([]); + const comparison = getComparisonDirectory(); + for (const [label, contents] of [ + ['expected', expected], + ['received', received], + ['difference', difference], + ]) { + expect(fs.readFileSync(path.join(comparison, `${label}.png`))).toEqual( + contents, + ); + } + expect(fs.readFileSync(result.expectedPath)).toEqual(expected); + expect(fs.readFileSync(result.receivedPath)).toEqual(received); + expect(fs.readFileSync(result.differencePath)).toEqual(difference); +}); + +it('omits passing screenshots and snapshots without saved captures', () => { + expect( + saveScreenshotArtifacts( + [ + {...result, pass: true}, + {...result, receivedPath: null}, + ], + artifacts, + ), + ).toEqual([]); + expect(fs.existsSync(artifacts)).toBe(false); +}); + +it('saves the received image when there is no baseline', () => { + fs.unlinkSync(result.expectedPath); + expect( + saveScreenshotArtifacts( + [{...result, isNew: true, differencePath: undefined}], + artifacts, + ), + ).toEqual([]); + const comparison = getComparisonDirectory(); + expect(fs.readdirSync(comparison)).toEqual(['received.png']); + expect(fs.readFileSync(path.join(comparison, 'received.png'))).toEqual( + received, + ); +}); + +it('retains earlier retries without overwriting their captures', () => { + saveScreenshotArtifacts([result], artifacts); + const captureDirectory = path.join(directory, 'attempt-2'); + fs.mkdirSync(captureDirectory); + const nextReceived = path.join(captureDirectory, 'image.png'); + fs.writeFileSync(nextReceived, expected); + expect( + saveScreenshotArtifacts( + [{...result, receivedPath: nextReceived}], + artifacts, + ), + ).toEqual([]); + expect( + fs.readFileSync(path.join(getComparisonDirectory(), 'received.png')), + ).toEqual(received); + expect( + fs.readFileSync( + path.join(getComparisonDirectory('attempt-2'), 'received.png'), + ), + ).toEqual(expected); +}); + +it('retains captures from different suites with matching directory and file names', () => { + const captureDirectory = path.join(directory, 'other-suite', 'attempt-1'); + fs.mkdirSync(captureDirectory, {recursive: true}); + const nextResult = { + ...result, + expectedPath: path.join(captureDirectory, 'expected.png'), + receivedPath: path.join(captureDirectory, 'image.png'), + differencePath: path.join(captureDirectory, 'image.diff.png'), + }; + fs.writeFileSync(nextResult.expectedPath, received); + fs.writeFileSync(nextResult.receivedPath, difference); + fs.writeFileSync(nextResult.differencePath, expected); + expect(saveScreenshotArtifacts([result], artifacts)).toEqual([]); + expect(saveScreenshotArtifacts([nextResult], artifacts)).toEqual([]); + const attemptDirectory = path.join(artifacts, 'attempt-1'); + const comparisons = fs.readdirSync(attemptDirectory).map(name => { + const comparison = path.join(attemptDirectory, name); + return ['expected', 'received', 'difference'].map(label => + fs.readFileSync(path.join(comparison, `${label}.png`)), + ); + }); + expect(comparisons.length).toBe(2); + expect(comparisons).toEqual( + expect.arrayContaining([ + [expected, received, difference], + [received, difference, expected], + ]), + ); +}); + +it('keeps available images when another image cannot be copied', () => { + fs.unlinkSync(result.expectedPath); + const errors = saveScreenshotArtifacts([result], artifacts); + expect(errors.length).toBe(1); + expect(errors[0]).toContain(result.expectedPath); + const comparison = getComparisonDirectory(); + expect(fs.readFileSync(path.join(comparison, 'received.png'))).toEqual( + received, + ); + expect(fs.readFileSync(path.join(comparison, 'difference.png'))).toEqual( + difference, + ); +}); diff --git a/private/react-native-fantom/reporter/__tests__/terminalCapabilities-test.js b/private/react-native-fantom/reporter/__tests__/terminalCapabilities-test.js new file mode 100644 index 000000000000..69937f3ef0ff --- /dev/null +++ b/private/react-native-fantom/reporter/__tests__/terminalCapabilities-test.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import queryImageSupport from '../terminalCapabilities'; +import {Buffer} from 'node:buffer'; +import {PassThrough} from 'node:stream'; + +class TerminalInput extends PassThrough { + isRaw: boolean = false; + + setRawMode(raw: boolean): this { + this.isRaw = raw; + return this; + } +} + +let input: TerminalInput; +const write = jest.fn<[string], boolean>(() => true); + +beforeEach(() => { + jest.useFakeTimers(); + input = new TerminalInput(); + input.pause(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + input.destroy(); + write.mockReset(); + jest.useRealTimers(); +}); + +it('confirms the device attributes reported by the VS Code image addon', async () => { + const result = queryImageSupport(input, {write}); + expect(write).toHaveBeenCalledWith('\x1b[c'); + expect(input.isRaw).toBe(true); + input.emit('data', Buffer.from('\x1b[?62;4;9;22c')); + expect(await result).toBe(true); + expect(input.isRaw).toBe(false); + expect(input.readableFlowing).toBe(false); + expect(input.listenerCount('data')).toBe(0); + expect(jest.getTimerCount()).toBe(0); +}); + +it('rejects device attributes without image support', async () => { + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('\x1b[?1;2c')); + expect(await result).toBe(false); + expect(jest.getTimerCount()).toBe(0); +}); + +it('keeps the text fallback when the terminal does not respond', async () => { + const result = queryImageSupport(input, {write}); + jest.advanceTimersByTime(500); + expect(await result).toBe(false); + expect(input.isRaw).toBe(false); + expect(input.readableFlowing).toBe(false); + expect(input.listenerCount('data')).toBe(0); +}); + +it('accepts fragmented replies and preserves unrelated input', async () => { + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('a\x1b[?62;')); + input.emit('data', Buffer.from('4;9;22cb')); + expect(await result).toBe(true); + expect(input.read()?.toString()).toBe('ab'); +}); + +it('does not mistake ordinary input for a device attributes reply', async () => { + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('[?62;4;9;22c')); + jest.advanceTimersByTime(500); + expect(await result).toBe(false); + expect(input.read()?.toString()).toBe('[?62;4;9;22c'); +}); + +it('preserves input when the capability check times out', async () => { + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('q')); + jest.advanceTimersByTime(500); + expect(await result).toBe(false); + expect(input.read()?.toString()).toBe('q'); +}); + +it('preserves an already active raw mode', async () => { + input.setRawMode(true); + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('\x1b[?62;4;9;22c')); + expect(await result).toBe(true); + expect(input.isRaw).toBe(true); +}); + +it('does not take input from another terminal reader', async () => { + for (const event of ['data', 'readable']) { + const listener = jest.fn(); + input.on(event, listener); + expect(await queryImageSupport(input, {write})).toBe(false); + expect(input.listenerCount(event)).toBe(1); + expect(write).not.toHaveBeenCalled(); + expect(input.isRaw).toBe(false); + input.removeListener(event, listener); + } +}); + +it('falls back and restores the input if the query cannot be written', async () => { + write.mockImplementation(() => { + throw new Error('terminal disconnected'); + }); + expect(await queryImageSupport(input, {write})).toBe(false); + expect(input.isRaw).toBe(false); + expect(input.listenerCount('data')).toBe(0); + expect(jest.getTimerCount()).toBe(0); +}); + +it('restores raw mode and buffered input even when pausing fails', async () => { + const result = queryImageSupport(input, {write}); + jest.spyOn(input, 'pause').mockImplementationOnce(() => { + throw new Error('pause failed'); + }); + input.emit('data', Buffer.from('q\x1b[?62;4;9;22c')); + expect(await result).toBe(false); + expect(input.isRaw).toBe(false); + expect(input.listenerCount('data')).toBe(0); + expect(jest.getTimerCount()).toBe(0); + expect(input.read()?.toString()).toBe('q'); +}); + +it('restores raw mode and buffered input when removing the listener fails', async () => { + const result = queryImageSupport(input, {write}); + jest.spyOn(input, 'removeListener').mockImplementationOnce(() => { + throw new Error('listener removal failed'); + }); + input.emit('data', Buffer.from('q\x1b[?62;4;9;22c')); + expect(await result).toBe(false); + expect(input.isRaw).toBe(false); + expect(input.readableFlowing).toBe(false); + expect(jest.getTimerCount()).toBe(0); + expect(input.read()?.toString()).toBe('q'); +}); + +it('restores raw mode before trying to put unrelated input back', async () => { + const result = queryImageSupport(input, {write}); + jest.spyOn(input, 'unshift').mockImplementationOnce(() => { + throw new Error('input closed'); + }); + input.emit('data', Buffer.from('q\x1b[?62;4;9;22c')); + expect(await result).toBe(false); + expect(input.isRaw).toBe(false); + expect(input.listenerCount('data')).toBe(0); +}); + +it('reports a failed raw-mode restore and keeps the text fallback', async () => { + jest.spyOn(input, 'setRawMode').mockImplementation(raw => { + if (!raw) { + throw new Error('raw-mode restore failed'); + } + input.isRaw = raw; + return input; + }); + const result = queryImageSupport(input, {write}); + input.emit('data', Buffer.from('\x1b[?62;4;9;22c')); + expect(await result).toBe(false); + expect(write).toHaveBeenLastCalledWith(expect.stringContaining('stty sane')); + expect(input.listenerCount('data')).toBe(0); + expect(input.readableFlowing).toBe(false); + expect(jest.getTimerCount()).toBe(0); +}); diff --git a/private/react-native-fantom/reporter/imageRow.js b/private/react-native-fantom/reporter/imageRow.js new file mode 100644 index 000000000000..fdaba0373964 --- /dev/null +++ b/private/react-native-fantom/reporter/imageRow.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {getPngDimensions} from '../runner/screenshots/pngUtils'; +import {Buffer} from 'node:buffer'; +import {PNG} from 'pngjs/lib/png'; + +export default function createImageRow(images: ReadonlyArray): ?Buffer { + const sizes = images.map(getPngDimensions); + if (sizes.length === 0 || sizes.some(size => size == null)) { + return null; + } + const panelWidth = Math.max(...sizes.map(size => size?.width ?? 0)); + const height = Math.max(...sizes.map(size => size?.height ?? 0)); + const gap = 16; + const width = panelWidth * images.length + gap * (images.length - 1); + if (width * height > 16 * 1024 * 1024) { + return null; + } + try { + const row = new PNG({width, height}); + for (const [index, image] of images.entries()) { + const png = PNG.sync.read(image); + for (let y = 0; y < png.height; y++) { + row.data.set( + png.data.subarray(y * png.width * 4, (y + 1) * png.width * 4), + (y * width + index * (panelWidth + gap)) * 4, + ); + } + } + return PNG.sync.write(row); + } catch { + return null; + } +} diff --git a/private/react-native-fantom/reporter/index.js b/private/react-native-fantom/reporter/index.js new file mode 100644 index 000000000000..53218653fc6e --- /dev/null +++ b/private/react-native-fantom/reporter/index.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import typeof createScreenshotReporterType from './ScreenshotReporter'; */ + +require('../../../scripts/shared/babelRegister').registerForMonorepo(); + +const createScreenshotReporter /*: createScreenshotReporterType */ = + require('./ScreenshotReporter').default; + +module.exports = createScreenshotReporter; diff --git a/private/react-native-fantom/reporter/screenshotArtifacts.js b/private/react-native-fantom/reporter/screenshotArtifacts.js new file mode 100644 index 000000000000..279dea58b130 --- /dev/null +++ b/private/react-native-fantom/reporter/screenshotArtifacts.js @@ -0,0 +1,55 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotResult} from '../runtime/ScreenshotContext'; + +import {createHash} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export default function saveScreenshotArtifacts( + results: ReadonlyArray, + directory: string, +): Array { + const errors = []; + for (const result of results) { + const receivedPath = result.receivedPath; + if (result.pass || receivedPath == null) { + continue; + } + const identity = createHash('sha256') + .update(path.resolve(receivedPath)) + .digest('hex'); + const destination = path.join( + directory, + path.basename(path.dirname(receivedPath)), + `${path.basename(receivedPath, '.png')}-${identity}`, + ); + const images = [ + ['expected', result.isNew ? null : result.expectedPath], + ['received', receivedPath], + ['difference', result.differencePath], + ]; + for (const [label, file] of images) { + if (file == null) { + continue; + } + try { + fs.mkdirSync(destination, {recursive: true}); + fs.copyFileSync(file, path.join(destination, `${label}.png`)); + } catch (error: unknown) { + errors.push( + `Failed to save screenshot artifact ${file}: ${String(error)}`, + ); + } + } + } + return errors; +} diff --git a/private/react-native-fantom/reporter/terminalCapabilities.js b/private/react-native-fantom/reporter/terminalCapabilities.js new file mode 100644 index 000000000000..64ac1cdfbdd6 --- /dev/null +++ b/private/react-native-fantom/reporter/terminalCapabilities.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {Buffer} from 'node:buffer'; + +interface TerminalInput { + readonly isRaw: boolean; + readonly readableFlowing: ?boolean; + listenerCount(string): number; + on('data', (Buffer | string) => void): unknown; + removeListener('data', (Buffer | string) => void): unknown; + setRawMode(boolean): unknown; + pause(): unknown; + unshift(Buffer): unknown; +} + +interface TerminalOutput { + write(string): unknown; +} + +export default function queryImageSupport( + input: TerminalInput, + output: TerminalOutput, +): Promise { + if (input.listenerCount('data') > 0 || input.listenerCount('readable') > 0) { + return Promise.resolve(false); + } + return new Promise(resolve => { + const wasRaw = input.isRaw; + const wasFlowing = input.readableFlowing === true; + let pending = Buffer.alloc(0); + let settled = false; + const finish = (supported: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + let supportsImages = supported; + try { + input.setRawMode(wasRaw); + } catch { + supportsImages = false; + try { + output.write( + 'Fantom could not restore terminal input mode. Run `stty sane` if keyboard input stops echoing.\n', + ); + } catch {} + } + try { + input.removeListener('data', onData); + } catch { + supportsImages = false; + } + try { + if (!wasFlowing) { + input.pause(); + } + } catch { + supportsImages = false; + } + try { + if (pending.length > 0) { + input.unshift(pending); + } + } catch { + supportsImages = false; + } + resolve(supportsImages); + }; + const onData = (data: Buffer | string) => { + if (settled) { + return; + } + pending = Buffer.concat([ + pending, + typeof data === 'string' ? Buffer.from(data) : data, + ]); + const text = pending.toString('latin1'); + const response = [...text.matchAll(/\[\?([0-9;]+)c/g)].find( + match => text[match.index - 1] === '\x1b', + ); + if (response != null) { + const offset = response.index - 1; + pending = Buffer.concat([ + pending.subarray(0, offset), + pending.subarray(response.index + response[0].length), + ]); + // VS Code's image addon reports Sixel support (device attribute 4). + finish(response[1].split(';').includes('4')); + } else if (pending.length > 4096) { + finish(false); + } + }; + const timeout = setTimeout(() => finish(false), 500); + try { + input.setRawMode(true); + input.on('data', onData); + output.write('\x1b[c'); + } catch { + finish(false); + } + }); +} diff --git a/private/react-native-fantom/reporter/terminalImages.js b/private/react-native-fantom/reporter/terminalImages.js new file mode 100644 index 000000000000..e92276e88ab1 --- /dev/null +++ b/private/react-native-fantom/reporter/terminalImages.js @@ -0,0 +1,85 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import {getPngDimensions} from '../runner/screenshots/pngUtils'; + +export type ImageProtocol = 'iterm2' | 'kitty'; +export type TerminalEnvironment = {[string]: string | void}; + +export function getImageProtocol( + isTTY: ?boolean, + env: TerminalEnvironment, + supportsVSCodeImages: boolean = false, +): ?ImageProtocol { + if ( + isTTY !== true || + (env.CI != null && env.CI !== '' && env.CI !== 'false' && env.CI !== '0') + ) { + return null; + } + if (env.TMUX != null || env.STY != null) { + return null; + } + if (env.TERM_PROGRAM === 'vscode') { + return supportsVSCodeImages ? 'iterm2' : null; + } + if (env.TERM === 'xterm-kitty' || env.KITTY_WINDOW_ID != null) { + return 'kitty'; + } + if ( + env.TERM_PROGRAM === 'iTerm.app' || + env.TERM_PROGRAM === 'WezTerm' || + env.LC_TERMINAL === 'iTerm2' + ) { + return 'iterm2'; + } + return null; +} + +export type TerminalImage = Readonly<{sequence: string, columns: number}>; + +export function encodeTerminalImage( + png: Buffer, + protocol: ImageProtocol, + options: Readonly<{columns: number, rows: number, tmux: boolean}>, +): ?TerminalImage { + const dimensions = getPngDimensions(png); + if (dimensions == null) { + return null; + } + const {width, height} = dimensions; + const maxColumns = Math.max(1, Math.min(options.columns, 80)); + const maxRows = Math.max(1, Math.min(options.rows, 24)); + const scale = Math.min(1, (maxColumns * 8) / width, (maxRows * 16) / height); + const imageColumns = Math.ceil((width * scale) / 8); + const data = png.toString('base64'); + const wrap = (sequence: string): string => + options.tmux + ? '\x1bPtmux;' + sequence.replaceAll('\x1b', '\x1b\x1b') + '\x1b\\' + : sequence; + if (protocol === 'iterm2') { + return { + columns: imageColumns, + sequence: wrap( + `\x1b]1337;File=inline=1;size=${png.length};width=${imageColumns};height=auto;preserveAspectRatio=1:${data}\x07`, + ), + }; + } + let output = ''; + for (let offset = 0; offset < data.length; offset += 4096) { + const chunk = data.slice(offset, offset + 4096); + const parameters = + offset === 0 ? `a=T,f=100,t=d,q=2,c=${imageColumns},` : ''; + output += wrap( + `\x1b_G${parameters}m=${offset + 4096 < data.length ? 1 : 0};${chunk}\x1b\\`, + ); + } + return {sequence: output, columns: imageColumns}; +} diff --git a/private/react-native-fantom/runner/__tests__/snapshotUtils-test.js b/private/react-native-fantom/runner/__tests__/snapshotUtils-test.js new file mode 100644 index 000000000000..62f355bc2334 --- /dev/null +++ b/private/react-native-fantom/runner/__tests__/snapshotUtils-test.js @@ -0,0 +1,153 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {updateSnapshotsAndGetJestSnapshotResult} from '../snapshotUtils'; +import {SnapshotState} from 'jest-snapshot'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +let directory: string; +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fantom-snapshot-test-')); +}); +afterEach(() => fs.rmSync(directory, {recursive: true, force: true})); + +it('does not overwrite an existing snapshot on mismatch without -u', () => { + const filePath = path.join(directory, 'example.snap'); + fs.writeFileSync(filePath, 'exports[`test 1`] = `old hash`;\n'); + for (const mode of ['new', 'none'] as const) { + const state = new SnapshotState(filePath, { + updateSnapshot: mode, + rootDir: directory, + snapshotFormat: {}, + }); + const result = updateSnapshotsAndGetJestSnapshotResult(state, [ + { + status: 'failed', + snapshotResults: {'test 1': {pass: false, value: 'new hash'}}, + }, + ]); + expect(result.unmatched).toBe(1); + expect(result.updated).toBe(0); + expect(fs.readFileSync(filePath, 'utf8')).toContain('old hash'); + expect(fs.readFileSync(filePath, 'utf8')).not.toContain('new hash'); + } +}); + +it('updates a hash once with -u and counts passing snapshots once', () => { + const filePath = path.join(directory, 'example.snap'); + fs.writeFileSync(filePath, 'exports[`test 1`] = `old hash`;\n'); + const state = new SnapshotState(filePath, { + updateSnapshot: 'all', + rootDir: directory, + snapshotFormat: {}, + }); + const result = updateSnapshotsAndGetJestSnapshotResult(state, [ + { + status: 'passed', + snapshotResults: {'test 1': {pass: false, value: 'new hash'}}, + }, + ]); + expect(result.updated).toBe(1); + expect(fs.readFileSync(filePath, 'utf8')).toContain('new hash'); + const matching = new SnapshotState(filePath, { + updateSnapshot: 'none', + rootDir: directory, + snapshotFormat: {}, + }); + const matched = updateSnapshotsAndGetJestSnapshotResult(matching, [ + { + status: 'passed', + snapshotResults: {'test 1': {pass: true}}, + }, + ]); + expect(matched.matched).toBe(1); + expect(matched.unchecked).toBe(0); +}); + +it('counts a new snapshot once', () => { + const state = new SnapshotState(path.join(directory, 'example.snap'), { + updateSnapshot: 'new', + rootDir: directory, + snapshotFormat: {}, + }); + const result = updateSnapshotsAndGetJestSnapshotResult(state, [ + { + status: 'passed', + snapshotResults: {'test 1': {pass: false, value: 'new hash'}}, + }, + ]); + expect(result.added).toBe(1); + expect(result.matched).toBe(0); + expect(result.updated).toBe(0); + expect(result.unmatched).toBe(0); +}); + +it('preserves unchecked snapshots when no tests ran with -u', () => { + const filePath = path.join(directory, 'example.snap'); + const contents = + '// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`test 1`] = `old hash`;\n'; + fs.writeFileSync(filePath, contents); + const state = new SnapshotState(filePath, { + updateSnapshot: 'all', + rootDir: directory, + snapshotFormat: {}, + }); + const result = updateSnapshotsAndGetJestSnapshotResult(state, []); + expect(fs.readFileSync(filePath, 'utf8')).toBe(contents); + expect(result.unchecked).toBe(0); + expect(result.uncheckedKeys).toEqual([]); +}); + +it('preserves unchecked snapshots after skipped or failed tests with -u', () => { + const filePath = path.join(directory, 'example.snap'); + const contents = + '// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`other test 1`] = `other hash`;\n\nexports[`removed test 1`] = `removed hash`;\n\nexports[`test 1`] = `old hash`;\n'; + for (const status of ['pending', 'failed'] as const) { + fs.writeFileSync(filePath, contents); + const state = new SnapshotState(filePath, { + updateSnapshot: 'all', + rootDir: directory, + snapshotFormat: {}, + }); + const result = updateSnapshotsAndGetJestSnapshotResult(state, [ + { + status: 'passed', + snapshotResults: {'test 1': {pass: true}}, + }, + {status, snapshotResults: {}}, + ]); + expect(fs.readFileSync(filePath, 'utf8')).toBe(contents); + expect(result.unchecked).toBe(0); + expect(result.uncheckedKeys).toEqual([]); + } +}); + +it('removes unchecked snapshots after a full successful update', () => { + const filePath = path.join(directory, 'example.snap'); + fs.writeFileSync( + filePath, + 'exports[`test 1`] = `old hash`;\nexports[`removed test 1`] = `removed hash`;\n', + ); + const state = new SnapshotState(filePath, { + updateSnapshot: 'all', + rootDir: directory, + snapshotFormat: {}, + }); + updateSnapshotsAndGetJestSnapshotResult(state, [ + { + status: 'passed', + snapshotResults: {'test 1': {pass: true}}, + }, + ]); + expect(fs.readFileSync(filePath, 'utf8')).toContain('old hash'); + expect(fs.readFileSync(filePath, 'utf8')).not.toContain('removed hash'); +}); diff --git a/private/react-native-fantom/runner/runner.js b/private/react-native-fantom/runner/runner.js index 999950b26476..9593a03a1786 100644 --- a/private/react-native-fantom/runner/runner.js +++ b/private/react-native-fantom/runner/runner.js @@ -8,6 +8,7 @@ * @format */ +import type {ScreenshotResult} from '../runtime/ScreenshotContext'; import type { FailureDetail, TestCaseResult, @@ -47,6 +48,12 @@ import { buildJSTracesOutputPath, getTestBuildOutputPath, } from './paths'; +import { + createScreenshotDifferences, + formatScreenshotFailure, + getScreenshotConfig, + processScreenshotResults, +} from './screenshots/screenshotUtils'; import { getInitialSnapshotData, processInlineSnapshotResults, @@ -64,6 +71,7 @@ import { // $FlowExpectedError[untyped-import] import {formatResultsErrors} from 'jest-message-util'; import {SnapshotState, buildSnapshotResolver} from 'jest-snapshot'; +import {randomUUID} from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; @@ -80,13 +88,23 @@ if (EnvironmentOptions.profileJS) { function buildError( failureDetail: FailureDetail, sourceMapPath: string, + screenshotResults: ReadonlyArray = [], ): Error { - const error = new Error(failureDetail.message); + const error = new Error( + formatScreenshotFailure(failureDetail.message, screenshotResults), + ); if (failureDetail.stack != null) { - error.stack = symbolicateStackTrace(sourceMapPath, failureDetail.stack); + error.stack = symbolicateStackTrace( + sourceMapPath, + formatScreenshotFailure(failureDetail.stack, screenshotResults), + ); } if (failureDetail.cause != null) { - error.cause = buildError(failureDetail.cause, sourceMapPath); + error.cause = buildError( + failureDetail.cause, + sourceMapPath, + screenshotResults, + ); } return error; } @@ -225,6 +243,14 @@ module.exports = async function runTest( let coverageMap: CoverageMap = {}; const snapshotResolver = await buildSnapshotResolver(config); const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); + const screenshotConfig = getScreenshotConfig( + snapshotPath, + path.join( + TEST_BUILD_OUTPUT_PATH, + 'screenshots', + `${randomUUID()}-${path.basename(testPath)}`, + ), + ); const snapshotState = new SnapshotState(snapshotPath, { updateSnapshot: globalConfig.updateSnapshot, snapshotFormat: config.snapshotFormat, @@ -257,12 +283,14 @@ module.exports = async function runTest( { ancestorTitles, duration: 0, - failureDetails: [] as Array, + failureDetails: [] as Array, failureMessages: [] as Array, + screenshotFailureMessages: undefined as Array | void, fullName: title, numPassingAsserts: 0, snapshotResults: {} as TestSnapshotResults, inlineSnapshotResults: [] as TestInlineSnapshotResults, + screenshotResults: [] as Array, status: 'pending' as TestCaseResult['status'], testFilePath: testPath, title, @@ -330,6 +358,10 @@ module.exports = async function runTest( snapshotConfig: { updateSnapshot: snapshotState._updateSnapshot, data: getInitialSnapshotData(snapshotState), + screenshots: { + ...screenshotConfig, + receivedDirectory: `${screenshotConfig.receivedDirectory}-${testResultsByConfig.length}`, + }, }, jsHeapSnapshotOutputPathTemplate, jsHeapSnapshotOutputPathTemplateToken, @@ -456,18 +488,36 @@ module.exports = async function runTest( } const testResults = - nullthrows(processedResult.testResults).map(testResult => ({ - ancestorTitles: [] as Array, - testFilePath: testPath, - ...testResult, - failureMessages: testResult.failureMessages.map(maybeStackTrace => - symbolicateStackTrace(sourceMapPath, maybeStackTrace), - ), - failureDetails: testResult.failureDetails.map(failureDetails => - buildError(failureDetails, sourceMapPath), - ), - snapshotResults: testResult.snapshotResults, - })) ?? []; + nullthrows(processedResult.testResults).map(testResult => { + const screenshotResults = createScreenshotDifferences( + testResult.screenshotResults, + globalConfig.updateSnapshot, + ); + return { + ancestorTitles: [] as Array, + testFilePath: testPath, + ...testResult, + screenshotResults, + failureMessages: testResult.failureMessages.map(maybeStackTrace => + symbolicateStackTrace( + sourceMapPath, + formatScreenshotFailure(maybeStackTrace, screenshotResults), + ), + ), + screenshotFailureMessages: testResult.screenshotFailureMessages?.map( + maybeStackTrace => + maybeStackTrace == null + ? null + : symbolicateStackTrace(sourceMapPath, maybeStackTrace), + ), + failureDetails: testResult.failureDetails.map(failureDetail => + failureDetail == null + ? undefined + : buildError(failureDetail, sourceMapPath, screenshotResults), + ), + snapshotResults: testResult.snapshotResults, + }; + }) ?? []; // Process inline snapshot results (requires source map for symbolication) if (hasInlineSnapshotUpdates) { @@ -553,13 +603,15 @@ module.exports = async function runTest( const testResults = testResultsByConfig.flat(); - const snapshotResults = testResults.map( - testResult => testResult.snapshotResults, + processScreenshotResults( + screenshotConfig, + testResults.flatMap(result => result.screenshotResults), + globalConfig.updateSnapshot, + testResults, ); - const snapshotResult = updateSnapshotsAndGetJestSnapshotResult( snapshotState, - snapshotResults, + testResults, ); printBenchmarkResultsRanking(benchmarkResults); @@ -572,6 +624,27 @@ module.exports = async function runTest( globalConfig, testPath, ), + screenshotFailureMessage: testResults.some( + result => result.screenshotFailureMessages != null, + ) + ? formatResultsErrors( + testResults.map(result => ({ + ...result, + failureMessages: result.failureMessages.map( + (message, index) => + result.screenshotFailureMessages?.[index] ?? message, + ), + failureDetails: result.failureDetails.map((detail, index) => + result.screenshotFailureMessages?.[index] != null + ? undefined + : detail, + ), + })), + config, + globalConfig, + testPath, + ) + : undefined, coverage: coverageMap, leaks: false, openHandles: [], diff --git a/private/react-native-fantom/runner/screenshots/__tests__/imageDiff-test.js b/private/react-native-fantom/runner/screenshots/__tests__/imageDiff-test.js new file mode 100644 index 000000000000..10783588b0d4 --- /dev/null +++ b/private/react-native-fantom/runner/screenshots/__tests__/imageDiff-test.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import createImageDiff from '../imageDiff'; +import {Buffer} from 'node:buffer'; +import {PNG} from 'pngjs/lib/png'; + +const png = PNG.sync.write(new PNG({width: 2, height: 1})); + +it('highlights changed pixels and fades unchanged content without modifying inputs', () => { + const baseline = new PNG({width: 2, height: 1}); + baseline.data.set([0, 0, 0, 255, 0, 0, 255, 255]); + const capture = new PNG({width: 2, height: 1}); + capture.data.set([0, 0, 0, 255, 0, 255, 0, 255]); + const expected = PNG.sync.write(baseline); + const received = PNG.sync.write(capture); + const originalExpected = Buffer.from(expected); + const originalReceived = Buffer.from(received); + const encoded = createImageDiff(expected, received); + expect(encoded).not.toBe(null); + if (encoded == null) { + return; + } + const diff = PNG.sync.read(encoded); + expect(diff.width).toBe(2); + expect(diff.height).toBe(1); + expect(diff.data[0]).toBeGreaterThan(200); + expect(diff.data[0]).toBe(diff.data[1]); + expect(diff.data[1]).toBe(diff.data[2]); + expect([...diff.data.subarray(4, 8)]).toEqual([255, 0, 0, 255]); + expect(expected.equals(originalExpected)).toBe(true); + expect(received.equals(originalReceived)).toBe(true); +}); + +it('marks alpha changes even when compositing onto white would hide them', () => { + const baseline = new PNG({width: 1, height: 1}); + baseline.data.set([255, 255, 255, 255]); + const capture = new PNG({width: 1, height: 1}); + capture.data.set([255, 255, 255, 128]); + const encoded = createImageDiff( + PNG.sync.write(baseline), + PNG.sync.write(capture), + ); + expect(encoded).not.toBe(null); + if (encoded != null) { + expect([...PNG.sync.read(encoded).data]).toEqual([255, 0, 0, 255]); + } +}); + +it('marks added and removed dimensions, including transparent pixels', () => { + const baseline = PNG.sync.write(new PNG({width: 2, height: 1})); + const capture = PNG.sync.write(new PNG({width: 1, height: 2})); + for (const [expected, received] of [ + [baseline, capture], + [capture, baseline], + ]) { + const encoded = createImageDiff(expected, received); + expect(encoded).not.toBe(null); + if (encoded == null) { + continue; + } + const diff = PNG.sync.read(encoded); + expect(diff.width).toBe(2); + expect(diff.height).toBe(2); + expect([...diff.data.subarray(0, 4)]).toEqual([255, 255, 255, 255]); + expect([...diff.data.subarray(4, 8)]).toEqual([255, 0, 0, 255]); + expect([...diff.data.subarray(8, 12)]).toEqual([255, 0, 0, 255]); + expect([...diff.data.subarray(12, 16)]).toEqual([255, 255, 255, 255]); + } +}); + +it('isolates corrupt PNGs and oversized diff canvases', () => { + expect(createImageDiff(Buffer.from('invalid'), png)).toBe(null); + const corrupt = Buffer.from(png); + corrupt[corrupt.length - 5] = (corrupt[corrupt.length - 5] + 1) % 256; + expect(createImageDiff(png, corrupt)).toBe(null); + const wide = Buffer.from(png); + wide.writeUInt32BE(16000, 16); + wide.writeUInt32BE(1, 20); + const tall = Buffer.from(png); + tall.writeUInt32BE(1, 16); + tall.writeUInt32BE(16000, 20); + expect(createImageDiff(wide, tall)).toBe(null); +}); + +it('rejects PNGs with conflicting dimension headers', () => { + const small = PNG.sync.write(new PNG({width: 1, height: 1})); + const wide = PNG.sync.write(new PNG({width: 10, height: 1})); + const conflicting = Buffer.concat([small.subarray(0, 33), wide.subarray(8)]); + expect(createImageDiff(conflicting, small)).toBe(null); + expect(createImageDiff(small, conflicting)).toBe(null); +}); diff --git a/private/react-native-fantom/runner/screenshots/__tests__/screenshotUtils-test.js b/private/react-native-fantom/runner/screenshots/__tests__/screenshotUtils-test.js new file mode 100644 index 000000000000..c0137bdd44ee --- /dev/null +++ b/private/react-native-fantom/runner/screenshots/__tests__/screenshotUtils-test.js @@ -0,0 +1,291 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotResult} from '../../../runtime/ScreenshotContext'; + +import { + createScreenshotDifferences, + formatScreenshotFailure, + getScreenshotConfig, + processScreenshotResults, +} from '../screenshotUtils'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {PNG} from 'pngjs/lib/png'; + +let directory: string; +const passedTests = [{status: 'passed' as const}]; +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fantom-screenshot-test-')); +}); +afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(directory, {recursive: true, force: true}); +}); + +function createResult( + isNew: boolean, + pass: boolean, +): {...ScreenshotResult, receivedPath: string} { + const config = getScreenshotConfig( + path.join(directory, '__snapshots__', 'example-test.js.snap'), + directory, + ); + const expectedPath = path.join(config.snapshotDirectory, 'image.png'); + const receivedPath = path.join(directory, 'actual.png'); + fs.writeFileSync(receivedPath, 'new image'); + if (!isNew) { + fs.mkdirSync(config.snapshotDirectory, {recursive: true}); + fs.writeFileSync(expectedPath, 'old image'); + } + return {fileName: 'image.png', expectedPath, receivedPath, pass, isNew}; +} + +function getConfig() { + return getScreenshotConfig( + path.join(directory, '__snapshots__', 'example-test.js.snap'), + directory, + ); +} + +function createPngResult(): {...ScreenshotResult, receivedPath: string} { + const result = createResult(false, false); + const baseline = new PNG({width: 1, height: 1}); + baseline.data.set([0, 0, 255, 255]); + const capture = new PNG({width: 1, height: 1}); + capture.data.set([0, 255, 0, 255]); + fs.writeFileSync(result.expectedPath, PNG.sync.write(baseline)); + fs.writeFileSync(result.receivedPath, PNG.sync.write(capture)); + return result; +} + +for (const mode of ['none', 'new'] as const) { + it(`saves a difference beside the received PNG in ${mode} mode`, () => { + const result = createPngResult(); + const baseline = fs.readFileSync(result.expectedPath); + const received = fs.readFileSync(result.receivedPath); + const [processed] = createScreenshotDifferences([result], mode); + const differencePath = processed.differencePath; + expect(differencePath).toBe(path.join(directory, 'actual.diff.png')); + if (differencePath == null) { + throw new Error('Missing difference PNG'); + } + expect([...PNG.sync.read(fs.readFileSync(differencePath)).data]).toEqual([ + 255, 0, 0, 255, + ]); + expect(fs.readFileSync(result.expectedPath).equals(baseline)).toBe(true); + expect(fs.readFileSync(result.receivedPath).equals(received)).toBe(true); + const message = `Expected screenshot: ${result.expectedPath}\nReceived screenshot: ${result.receivedPath}\nOpen these files.`; + expect(formatScreenshotFailure(message, [processed])).toContain( + `Received screenshot: ${result.receivedPath}\nDifference screenshot: ${differencePath}\nOpen these files.`, + ); + expect(formatScreenshotFailure('unrelated error', [processed])).toBe( + 'unrelated error', + ); + }); +} + +it('adds each difference to the matching diagnostic block without matching path prefixes', () => { + const result = createResult(false, false); + const first = {...result, differencePath: `${directory}/first.diff.png`}; + const second = { + ...result, + receivedPath: `${result.receivedPath}.other.png`, + differencePath: `${directory}/second.$&.diff.png`, + }; + const block = (item: Readonly<{...ScreenshotResult, receivedPath: string}>) => + `Expected screenshot: ${item.expectedPath}\nReceived screenshot: ${item.receivedPath}`; + const message = `${block(second)}\nOther error\n${block(first)}`; + const expected = `${block(second)}\nDifference screenshot: ${second.differencePath}\nOther error\n${block(first)}\nDifference screenshot: ${first.differencePath}`; + expect(formatScreenshotFailure(message, [first, second])).toBe(expected); + expect(formatScreenshotFailure(expected, [first, second])).toBe(expected); +}); + +it('formats repeated diagnostic blocks without changing unrelated path mentions', () => { + const result = createResult(false, false); + const processed = {...result, differencePath: `${directory}/actual.diff.png`}; + const block = `Expected screenshot: ${result.expectedPath}\nReceived screenshot: ${result.receivedPath}`; + const unrelated = `Other error mentions Received screenshot: ${result.receivedPath}`; + const formatted = `${block}\nDifference screenshot: ${processed.differencePath}`; + expect( + formatScreenshotFailure(`${unrelated}\n${block}\n${block}`, [processed]), + ).toBe(`${unrelated}\n${formatted}\n${formatted}`); + expect( + formatScreenshotFailure(`Received screenshot: ${result.receivedPath}`, [ + processed, + ]), + ).toBe(`Received screenshot: ${result.receivedPath}`); +}); + +it('does not generate differences for passes, updates or missing baselines', () => { + const result = createPngResult(); + for (const unchanged of [ + {...result, pass: true}, + {...result, isNew: true}, + {...result, receivedPath: null}, + ]) { + expect(createScreenshotDifferences([unchanged], 'none')[0]).toBe(unchanged); + } + expect(createScreenshotDifferences([result], 'all')[0]).toBe(result); + expect(fs.existsSync(path.join(directory, 'actual.diff.png'))).toBe(false); +}); + +it('continues generating differences after missing, corrupt or unwritable files', () => { + const result = createPngResult(); + const corrupt = path.join(directory, 'corrupt.png'); + fs.writeFileSync(corrupt, 'not a PNG'); + const writeFileSync = fs.writeFileSync; + jest.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => { + if (file === path.join(directory, 'unwritable.diff.png')) { + throw new Error('Cannot write difference'); + } + return writeFileSync(file, data, options); + }); + const unwritable = path.join(directory, 'unwritable.png'); + fs.copyFileSync(result.receivedPath, unwritable); + const results = createScreenshotDifferences( + [ + {...result, expectedPath: path.join(directory, 'missing.png')}, + {...result, receivedPath: corrupt}, + {...result, receivedPath: unwritable}, + result, + ], + 'none', + ); + for (const failed of results.slice(0, 3)) { + expect(failed.differencePath).toBe(undefined); + } + expect(results[3].differencePath).toBe( + path.join(directory, 'actual.diff.png'), + ); +}); + +it('creates new images next to Jest snapshots', () => { + const result = createResult(true, false); + processScreenshotResults(getConfig(), [result], 'new', passedTests); + expect(result.expectedPath).toContain( + '__snapshots__/example-test.js.screenshots/', + ); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('new image'); + expect( + result.receivedPath == null || !fs.existsSync(result.receivedPath), + ).toBe(true); +}); + +it('preserves both images for a mismatch without -u', () => { + const result = createResult(false, false); + processScreenshotResults(getConfig(), [result], 'new', [{status: 'failed'}]); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); + expect(fs.readFileSync(result.receivedPath, 'utf8')).toBe('new image'); +}); + +it('updates existing baselines with -u', () => { + const result = createResult(false, false); + processScreenshotResults(getConfig(), [result], 'all', passedTests); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('new image'); +}); + +it('tolerates a received image disappearing after it is copied', () => { + const result = createResult(false, false); + const copyFileSync = fs.copyFileSync; + jest.spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { + copyFileSync(source, destination); + fs.unlinkSync(source); + }); + processScreenshotResults(getConfig(), [result], 'all', passedTests); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('new image'); +}); + +it('fails when a received image is missing before it can be copied', () => { + const result = createResult(false, false); + fs.unlinkSync(result.receivedPath); + expect(() => + processScreenshotResults(getConfig(), [result], 'all', passedTests), + ).toThrow(); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); +}); + +it('continues saving other captures after a copy error and retains the failed capture', () => { + const result = createResult(false, false); + const config = getConfig(); + const otherReceivedPath = path.join(directory, 'other.png'); + const otherExpectedPath = path.join(config.snapshotDirectory, 'other.png'); + fs.writeFileSync(otherReceivedPath, 'other image'); + const other = { + ...result, + fileName: 'other.png', + expectedPath: otherExpectedPath, + receivedPath: otherReceivedPath, + }; + expect(() => + processScreenshotResults( + config, + [{...result, expectedPath: config.snapshotDirectory}, other], + 'all', + passedTests, + ), + ).toThrow(config.snapshotDirectory); + expect(fs.readFileSync(result.receivedPath, 'utf8')).toBe('new image'); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); + expect(fs.readFileSync(otherExpectedPath, 'utf8')).toBe('other image'); + expect(fs.existsSync(otherReceivedPath)).toBe(false); +}); + +it('does not create missing baselines in CI', () => { + const result = createResult(true, false); + processScreenshotResults(getConfig(), [result], 'none', [{status: 'failed'}]); + expect(fs.existsSync(result.expectedPath)).toBe(false); + expect(fs.existsSync(result.receivedPath)).toBe(true); +}); + +it('keeps existing images for successful hash matches', () => { + const result = createResult(false, true); + fs.unlinkSync(result.receivedPath); + const matchingResult = {...result, receivedPath: null}; + processScreenshotResults(getConfig(), [matchingResult], 'none', passedTests); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); + expect( + result.receivedPath == null || !fs.existsSync(result.receivedPath), + ).toBe(true); +}); + +for (const mode of ['new', 'none', 'all'] as const) { + it(`recreates a missing image with a matching hash in ${mode} mode`, () => { + const result = createResult(false, true); + fs.unlinkSync(result.expectedPath); + processScreenshotResults(getConfig(), [result], mode, passedTests); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('new image'); + expect(fs.existsSync(result.receivedPath)).toBe(false); + }); +} + +it('preserves unchecked baselines when no tests ran', () => { + const result = createResult(false, true); + processScreenshotResults(getConfig(), [], 'all', []); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); +}); + +for (const status of ['failed', 'pending'] as const) { + it(`preserves unchecked baselines after a ${status} test`, () => { + const result = createResult(false, true); + processScreenshotResults(getConfig(), [], 'all', [ + ...passedTests, + {status}, + ]); + expect(fs.readFileSync(result.expectedPath, 'utf8')).toBe('old image'); + }); +} + +it('removes unchecked baselines with -u after a successful suite', () => { + const result = createResult(false, true); + processScreenshotResults(getConfig(), [], 'all', passedTests); + expect(fs.existsSync(result.expectedPath)).toBe(false); +}); diff --git a/private/react-native-fantom/runner/screenshots/imageDiff.js b/private/react-native-fantom/runner/screenshots/imageDiff.js new file mode 100644 index 000000000000..f12c08670a80 --- /dev/null +++ b/private/react-native-fantom/runner/screenshots/imageDiff.js @@ -0,0 +1,69 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import {getPngDimensions} from './pngUtils'; +import {Buffer} from 'node:buffer'; +import {PNG} from 'pngjs/lib/png'; + +export default function createImageDiff( + expected: Buffer, + received: Buffer, +): ?Buffer { + const expectedSize = getPngDimensions(expected); + const receivedSize = getPngDimensions(received); + if (expectedSize == null || receivedSize == null) { + return null; + } + const width = Math.max(expectedSize.width, receivedSize.width); + const height = Math.max(expectedSize.height, receivedSize.height); + if (width * height > 16 * 1024 * 1024) { + return null; + } + try { + const baseline = PNG.sync.read(expected); + const capture = PNG.sync.read(received); + const diff = new PNG({width, height}); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const a = (y * baseline.width + x) * 4; + const b = (y * capture.width + x) * 4; + const offset = (y * width + x) * 4; + const inBaseline = x < baseline.width && y < baseline.height; + const inCapture = x < capture.width && y < capture.height; + const changed = + inBaseline !== inCapture || + (inBaseline && + inCapture && + (baseline.data[a] !== capture.data[b] || + baseline.data[a + 1] !== capture.data[b + 1] || + baseline.data[a + 2] !== capture.data[b + 2] || + baseline.data[a + 3] !== capture.data[b + 3])); + const shade = inBaseline + ? Math.round( + 255 - + (255 - + (baseline.data[a] * 0.2126 + + baseline.data[a + 1] * 0.7152 + + baseline.data[a + 2] * 0.0722)) * + (baseline.data[a + 3] / 255) * + 0.15, + ) + : 255; + diff.data[offset] = changed ? 255 : shade; + diff.data[offset + 1] = changed ? 0 : shade; + diff.data[offset + 2] = changed ? 0 : shade; + diff.data[offset + 3] = 255; + } + } + return PNG.sync.write(diff); + } catch { + return null; + } +} diff --git a/private/react-native-fantom/runner/screenshots/pngUtils.js b/private/react-native-fantom/runner/screenshots/pngUtils.js new file mode 100644 index 000000000000..84aa4d88b644 --- /dev/null +++ b/private/react-native-fantom/runner/screenshots/pngUtils.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import {Buffer} from 'node:buffer'; + +export function getPngDimensions( + png: Buffer, +): ?Readonly<{width: number, height: number}> { + if ( + png.length < 33 || + png.length > 32 * 1024 * 1024 || + !png + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || + png.toString('ascii', 12, 16) !== 'IHDR' || + png.readUInt32BE(8) !== 13 + ) { + return null; + } + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + if (width === 0 || height === 0 || width * height > 16 * 1024 * 1024) { + return null; + } + let offset = 33; + while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + const type = png.toString('ascii', offset + 4, offset + 8); + const end = offset + length + 12; + if (end > png.length || type === 'IHDR') { + return null; + } + if (type === 'IEND') { + return length === 0 ? {width, height} : null; + } + offset = end; + } + return null; +} diff --git a/private/react-native-fantom/runner/screenshots/screenshotUtils.js b/private/react-native-fantom/runner/screenshots/screenshotUtils.js new file mode 100644 index 000000000000..a9630ad889f6 --- /dev/null +++ b/private/react-native-fantom/runner/screenshots/screenshotUtils.js @@ -0,0 +1,148 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type { + ScreenshotConfig, + ScreenshotResult, + UpdateSnapshot, +} from '../../runtime/ScreenshotContext'; +import type {TestCaseResult} from '../../runtime/setup'; + +import createImageDiff from './imageDiff'; +import fs from 'node:fs'; +import path from 'node:path'; + +export function getScreenshotConfig( + snapshotPath: string, + receivedDirectory: string, +): ScreenshotConfig { + const snapshotDirectory = path.join( + path.dirname(snapshotPath), + `${path.basename(snapshotPath, '.snap')}.screenshots`, + ); + return { + snapshotDirectory, + receivedDirectory, + files: fs.existsSync(snapshotDirectory) + ? fs.readdirSync(snapshotDirectory).filter(file => file.endsWith('.png')) + : [], + }; +} + +export function createScreenshotDifferences( + results: ReadonlyArray, + updateSnapshot: UpdateSnapshot, +): Array { + return results.map(result => { + const receivedPath = result.receivedPath; + if ( + result.pass || + result.isNew || + receivedPath == null || + updateSnapshot === 'all' + ) { + return result; + } + try { + const difference = createImageDiff( + fs.readFileSync(result.expectedPath), + fs.readFileSync(receivedPath), + ); + if (difference == null) { + return result; + } + const differencePath = path.join( + path.dirname(receivedPath), + `${path.basename(receivedPath, '.png')}.diff.png`, + ); + fs.writeFileSync(differencePath, difference); + return {...result, differencePath}; + } catch { + return result; + } + }); +} + +export function formatScreenshotFailure( + message: string, + results: ReadonlyArray, +): string { + const differences = new Map(); + for (const result of results) { + if (result.differencePath != null && result.receivedPath != null) { + differences.set( + `Expected screenshot: ${result.expectedPath}\nReceived screenshot: ${result.receivedPath}`, + `Difference screenshot: ${result.differencePath}`, + ); + } + } + const lines = message.split('\n'); + return lines + .flatMap((line, index) => { + const difference = differences.get(`${lines[index - 1]}\n${line}`); + return difference == null || lines[index + 1] === difference + ? [line] + : [line, difference]; + }) + .join('\n'); +} + +export function processScreenshotResults( + config: ScreenshotConfig, + results: ReadonlyArray, + updateSnapshot: UpdateSnapshot, + testResults: ReadonlyArray>, +): void { + const used = new Set(); + const errors: Array = []; + for (const result of results) { + used.add(result.fileName); + const receivedPath = result.receivedPath; + if (receivedPath == null) { + continue; + } + if ( + !result.pass && + updateSnapshot !== 'all' && + !(result.isNew && updateSnapshot === 'new') + ) { + continue; + } + try { + fs.mkdirSync(config.snapshotDirectory, {recursive: true}); + fs.copyFileSync(receivedPath, result.expectedPath); + fs.rmSync(receivedPath, {force: true}); + } catch (error: unknown) { + errors.push(`${result.expectedPath}: ${String(error)}`); + } + } + if ( + errors.length === 0 && + updateSnapshot === 'all' && + testResults.length > 0 && + testResults.every(result => result.status === 'passed') + ) { + for (const file of config.files) { + if (!used.has(file)) { + const imagePath = path.join(config.snapshotDirectory, file); + try { + fs.rmSync(imagePath, {force: true}); + } catch (error: unknown) { + errors.push(`${imagePath}: ${String(error)}`); + } + } + } + } + if (errors.length > 0) { + throw new Error( + `Failed to process Fantom screenshot snapshots:\n${errors.join('\n')}`, + ); + } +} diff --git a/private/react-native-fantom/runner/snapshotUtils.js b/private/react-native-fantom/runner/snapshotUtils.js index c6bcc9ec5b5e..ff1edbf4629c 100644 --- a/private/react-native-fantom/runner/snapshotUtils.js +++ b/private/react-native-fantom/runner/snapshotUtils.js @@ -8,17 +8,15 @@ * @format */ -import type { - TestInlineSnapshotResults, - TestSnapshotResults, -} from '../runtime/snapshotContext'; +import type {TestCaseResult} from '../runtime/setup'; +import type {TestInlineSnapshotResults} from '../runtime/snapshotContext'; import type {SnapshotState} from 'jest-snapshot'; import {symbolicateStackTrace} from './utils'; import fs from 'node:fs'; import path from 'node:path'; -type JestSnapshotResult = { +export type JestSnapshotResult = { added: number, fileDeleted: boolean, matched: number, @@ -295,9 +293,15 @@ export function saveInlineSnapshotsToSource( export const updateSnapshotsAndGetJestSnapshotResult = ( snapshotState: SnapshotState, - testSnapshotResults: Array, + testResults: ReadonlyArray< + Readonly<{ + status: TestCaseResult['status'], + snapshotResults: TestCaseResult['snapshotResults'], + ... + }>, + >, ): JestSnapshotResult => { - for (const snapshotResults of testSnapshotResults) { + for (const {snapshotResults} of testResults) { for (const [key, result] of Object.entries(snapshotResults)) { if (result.pass) { snapshotState.matched++; @@ -314,12 +318,16 @@ export const updateSnapshotsAndGetJestSnapshotResult = ( snapshotState._dirty = true; snapshotState._snapshotData[key] = addExtraLineBreaks(result.value); snapshotState.added++; - snapshotState.matched++; snapshotState._uncheckedKeys.delete(key); continue; } + if (snapshotState._updateSnapshot !== 'all') { + snapshotState.unmatched++; + snapshotState._uncheckedKeys.delete(key); + continue; + } snapshotState._dirty = true; snapshotState._snapshotData[key] = addExtraLineBreaks(result.value); snapshotState.updated++; @@ -327,8 +335,11 @@ export const updateSnapshotsAndGetJestSnapshotResult = ( } } - const uncheckedCount = snapshotState.getUncheckedCount(); - const uncheckedKeys = snapshotState.getUncheckedKeys(); + const allTestsPassed = + testResults.length > 0 && + testResults.every(result => result.status === 'passed'); + const uncheckedCount = allTestsPassed ? snapshotState.getUncheckedCount() : 0; + const uncheckedKeys = allTestsPassed ? snapshotState.getUncheckedKeys() : []; if (uncheckedCount) { snapshotState.removeUncheckedKeys(); } @@ -338,7 +349,8 @@ export const updateSnapshotsAndGetJestSnapshotResult = ( added: snapshotState.added, fileDeleted: status.deleted, matched: snapshotState.matched, - unchecked: status.deleted ? 0 : snapshotState.getUncheckedCount(), + unchecked: + status.deleted || !allTestsPassed ? 0 : snapshotState.getUncheckedCount(), uncheckedKeys: [...uncheckedKeys], unmatched: snapshotState.unmatched, updated: snapshotState.updated, diff --git a/private/react-native-fantom/runtime/ScreenshotContext.js b/private/react-native-fantom/runtime/ScreenshotContext.js new file mode 100644 index 000000000000..813ad9d86172 --- /dev/null +++ b/private/react-native-fantom/runtime/ScreenshotContext.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type Screenshot from '../src/Screenshot'; + +export type ScreenshotConfig = Readonly<{ + snapshotDirectory: string, + receivedDirectory: string, + files: ReadonlyArray, +}>; + +export type ScreenshotResult = Readonly<{ + fileName: string, + expectedPath: string, + receivedPath: ?string, + differencePath?: string, + pass: boolean, + isNew: boolean, +}>; + +export type UpdateSnapshot = 'all' | 'new' | 'none'; + +function getFileName(key: string): string { + let hash = 0; + let secondHash = 0; + for (let index = 0; index < key.length; index++) { + const character = key.charCodeAt(index); + hash = (hash * 31 + character) % 4294967291; + secondHash = (secondHash * 37 + character) % 4294967279; + } + const name = key.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100); + return `${name}-${hash.toString(16)}-${secondHash.toString(16)}.png`; +} + +export class ScreenshotContext { + #config: ?ScreenshotConfig; + #results: Array = []; + + configure(config: ?ScreenshotConfig): void { + this.#config = config; + } + + setTargetTest(_testFullName: string): void { + this.#results = []; + } + + record( + screenshot: Screenshot, + key: string, + matchesHash: boolean, + isNew: boolean, + ): boolean { + const config = this.#config; + if (config == null) { + throw new Error( + 'Screenshot snapshots must run inside a Fantom Jest test.', + ); + } + const fileName = getFileName(key); + const expectedPath = `${config.snapshotDirectory}/${fileName}`; + const receivedPath = + matchesHash && config.files.includes(fileName) + ? null + : `${config.receivedDirectory}/${fileName}`; + if (receivedPath != null) { + screenshot.saveToFile(receivedPath); + } + this.#results.push({ + fileName, + expectedPath, + receivedPath, + pass: matchesHash, + isNew, + }); + return matchesHash; + } + + getFailureMessage(): string { + const result = this.#results[this.#results.length - 1]; + const receivedPath = result?.receivedPath; + if (receivedPath == null) { + throw new Error('No failing screenshot to report.'); + } + return `Expected screenshot: ${result.expectedPath}\nReceived screenshot: ${receivedPath}\nOpen these files to compare the images. Rerun the test with --updateSnapshot (-u) to accept the new screenshot.`; + } + + getResults(): Array { + return [...this.#results]; + } +} + +export const screenshotContext: ScreenshotContext = new ScreenshotContext(); diff --git a/private/react-native-fantom/runtime/__tests__/ScreenshotContext-test.js b/private/react-native-fantom/runtime/__tests__/ScreenshotContext-test.js new file mode 100644 index 000000000000..b37038760c56 --- /dev/null +++ b/private/react-native-fantom/runtime/__tests__/ScreenshotContext-test.js @@ -0,0 +1,307 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScreenshotConfig} from '../ScreenshotContext'; + +import Screenshot from '../../src/Screenshot'; +import runtimeExpect from '../expect'; +import {ScreenshotContext, screenshotContext} from '../ScreenshotContext'; +import { + ScreenshotSnapshotError, + setupSnapshotConfig, + snapshotContext, +} from '../snapshotContext'; + +function createScreenshot() { + const saveToFile = jest.fn<[string], void>(); + const screenshot = new Screenshot({ + saveToFile, + getHash: () => 'pixel-hash', + isEqual: () => true, + }); + return {screenshot, saveToFile}; +} + +function createContext(files: Array = []): ScreenshotContext { + const context = new ScreenshotContext(); + context.configure({ + snapshotDirectory: '/snapshots', + receivedDirectory: '/received', + files, + }); + context.setTargetTest('Example renders'); + return context; +} + +it('reports a missing failure without an incidental property access error', () => { + expect(() => createContext().getFailureMessage()).toThrow( + 'No failing screenshot to report.', + ); +}); + +for (const mode of ['new', 'none', 'all'] as const) { + it(`compares inline screenshot hashes without saving images in ${mode} mode`, () => { + const {screenshot, saveToFile} = createScreenshot(); + const serialized = + '\nObject {\n "hash": "pixel-hash",\n "type": "Screenshot",\n}\n'; + setupSnapshotConfig({updateSnapshot: mode, data: {}}); + snapshotContext.setTargetTest('inline screenshot'); + runtimeExpect(screenshot).toMatchInlineSnapshot(serialized); + expect(snapshotContext.getInlineSnapshotResults()).toEqual([{pass: true}]); + + const mismatch = () => + runtimeExpect(screenshot).toMatchInlineSnapshot( + serialized.replace('pixel-hash', 'old-hash'), + ); + if (mode === 'all') { + mismatch(); + } else { + let message; + try { + mismatch(); + } catch (error) { + message = error.message; + } + expect(message).toContain('Expected to match inline snapshot.'); + } + expect(snapshotContext.getInlineSnapshotResults()[1].value).toBe( + serialized, + ); + expect(snapshotContext.getSnapshotResults()).toEqual({}); + expect(screenshotContext.getResults()).toEqual([]); + expect(saveToFile).not.toHaveBeenCalled(); + }); +} + +it('creates an inline screenshot hash without saving an image', () => { + const {screenshot, saveToFile} = createScreenshot(); + setupSnapshotConfig({updateSnapshot: 'new', data: {}}); + snapshotContext.setTargetTest('inline screenshot'); + runtimeExpect(screenshot).toMatchInlineSnapshot(); + expect(snapshotContext.getInlineSnapshotResults()[0].isNew).toBe(true); + expect(snapshotContext.getInlineSnapshotResults()[0].value).toContain( + '"hash": "pixel-hash"', + ); + expect(screenshotContext.getResults()).toEqual([]); + expect(saveToFile).not.toHaveBeenCalled(); +}); + +it('writes an inspectable image for a new snapshot and exposes failure paths', () => { + const context = createContext(); + const {screenshot, saveToFile} = createScreenshot(); + expect(context.record(screenshot, 'Example renders 1', false, true)).toBe( + false, + ); + const result = context.getResults()[0]; + expect(result.isNew).toBe(true); + expect(saveToFile).toHaveBeenCalledWith(result.receivedPath); + expect(context.getFailureMessage()).toContain(result.expectedPath); + expect(context.getFailureMessage()).toContain('--updateSnapshot'); +}); + +it('does not encode or save a passing screenshot', () => { + const {screenshot, saveToFile} = createScreenshot(); + const initial = createContext(); + initial.record(screenshot, 'Example renders 1', false, true); + const context = createContext([initial.getResults()[0].fileName]); + saveToFile.mockClear(); + expect(context.record(screenshot, 'Example renders 1', true, false)).toBe( + true, + ); + expect(saveToFile).not.toHaveBeenCalled(); + expect(context.getResults()[0].receivedPath).toBe(null); +}); + +it('saves a matching screenshot when its standalone image is missing', () => { + const context = createContext(); + const {screenshot, saveToFile} = createScreenshot(); + expect(context.record(screenshot, 'Example renders 1', true, false)).toBe( + true, + ); + expect(saveToFile).toHaveBeenCalledTimes(1); + expect(context.getResults()[0].pass).toBe(true); +}); + +for (const mode of ['new', 'none', 'all'] as const) { + it(`matches the stored hash without an image in ${mode} mode`, () => { + const {screenshot, saveToFile} = createScreenshot(); + setupSnapshotConfig({ + updateSnapshot: mode, + data: {'Example renders 1': 'pixel-hash'}, + screenshots: { + snapshotDirectory: '/snapshots', + receivedDirectory: '/received', + files: [], + }, + }); + snapshotContext.setTargetTest('Example renders'); + screenshotContext.setTargetTest('Example renders'); + snapshotContext.toMatchSnapshot('pixel-hash', null, screenshot); + expect(snapshotContext.getSnapshotResults()['Example renders 1'].pass).toBe( + true, + ); + expect(saveToFile).toHaveBeenCalledTimes(1); + }); +} + +it('keeps names bounded and distinguishes sanitized snapshot keys', () => { + const context = createContext(); + const {screenshot} = createScreenshot(); + context.record(screenshot, `${'a'.repeat(400)}: ../first 1`, false, true); + context.record(screenshot, `${'a'.repeat(400)}: ..\\first 1`, false, true); + const [first, second] = context.getResults(); + expect(first.fileName.length).toBeLessThan(255); + expect(first.fileName).not.toContain('/'); + expect(first.fileName).not.toBe(second.fileName); +}); + +it('uses normal snapshot keys and honors new, update, and CI modes', () => { + const {screenshot} = createScreenshot(); + const config: ScreenshotConfig = { + snapshotDirectory: '/snapshots', + receivedDirectory: '/received', + files: [], + }; + const key = 'Example renders: image 2'; + setupSnapshotConfig({updateSnapshot: 'new', data: {}, screenshots: config}); + snapshotContext.setTargetTest('Example renders'); + screenshotContext.setTargetTest('Example renders'); + snapshotContext.toMatchSnapshot('text', null); + snapshotContext.toMatchSnapshot('pixel-hash', 'image', screenshot); + expect(Object.keys(snapshotContext.getSnapshotResults())).toEqual([ + 'Example renders 1', + key, + ]); + const fileName = screenshotContext.getResults()[0].fileName; + for (const mode of ['new', 'none', 'all'] as const) { + setupSnapshotConfig({ + updateSnapshot: mode, + data: {'Example renders 1': 'text', [key]: 'old-hash'}, + screenshots: {...config, files: [fileName]}, + }); + snapshotContext.setTargetTest('Example renders'); + snapshotContext.toMatchSnapshot('text', null); + const match = () => + snapshotContext.toMatchSnapshot('pixel-hash', 'image', screenshot); + if (mode === 'all') { + match(); + } else { + expect(match).toThrow('Expected to match snapshot.'); + } + expect(snapshotContext.getSnapshotResults()[key]).toEqual({ + pass: false, + value: 'pixel-hash', + }); + } +}); + +it('requires configuration and propagates save errors', () => { + const {screenshot} = createScreenshot(); + expect(() => + new ScreenshotContext().record(screenshot, 'test 1', false, true), + ).toThrow('inside a Fantom Jest test'); + const failure = new Screenshot({ + saveToFile: () => { + throw new Error('Cannot save PNG'); + }, + getHash: () => 'hash', + isEqual: () => false, + }); + expect(() => createContext().record(failure, 'test 1', false, true)).toThrow( + 'Cannot save PNG', + ); +}); + +it('retains text diagnostics and a preview alternative for screenshot failures', () => { + const {screenshot} = createScreenshot(); + const config = { + updateSnapshot: 'none' as const, + screenshots: { + snapshotDirectory: '/snapshots', + receivedDirectory: '/received', + files: [], + }, + }; + for (const data of [{}, {'Example renders 1': 'old-hash'}]) { + setupSnapshotConfig({...config, data}); + snapshotContext.setTargetTest('Example renders'); + screenshotContext.setTargetTest('Example renders'); + let failure; + try { + snapshotContext.toMatchSnapshot( + 'Screenshot {hash: pixel-hash}', + null, + screenshot, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ScreenshotSnapshotError); + if (!(failure instanceof ScreenshotSnapshotError)) { + return; + } + expect(failure.message).toContain('/snapshots'); + const preview = failure.getPreviewMessage(); + expect(preview).toContain('screenshot snapshot'); + expect(preview).not.toContain('old-hash'); + expect(preview).not.toContain('pixel-hash'); + expect(preview).not.toContain('Screenshot {'); + expect(preview).not.toContain('/snapshots'); + expect(snapshotContext.getSnapshotResults()['Example renders 1'].pass).toBe( + false, + ); + } +}); + +it('preserves text diagnostics for unsupported terminals and ordinary snapshots', () => { + const {screenshot} = createScreenshot(); + for (const isScreenshot of [false, true]) { + setupSnapshotConfig({ + updateSnapshot: 'none', + data: {'Example renders 1': 'old-hash'}, + screenshots: { + snapshotDirectory: '/snapshots', + receivedDirectory: '/received', + files: [], + }, + }); + snapshotContext.setTargetTest('Example renders'); + screenshotContext.setTargetTest('Example renders'); + expect(() => + snapshotContext.toMatchSnapshot( + 'pixel-hash', + null, + isScreenshot ? screenshot : undefined, + ), + ).toThrow('old-hash'); + } +}); + +it('keeps preview diagnostics independent of a rewritten error stack', () => { + const message = + 'Expected old-hash, received new-hash\nReceived: /received/image.png'; + const failure = new ScreenshotSnapshotError( + message, + 'Expected to match screenshot snapshot.', + ); + for (const stack of [ + 'Error: Expected old-hash, received new-hash\n at assertion (test.js:10:2)', + ' at assertion (test.js:10:2)', + ]) { + failure.stack = stack; + const preview = failure.getPreviewMessage(); + expect(preview).toContain('Expected to match screenshot snapshot.'); + expect(preview).not.toContain('old-hash'); + expect(preview).not.toContain('new-hash'); + expect(preview).not.toContain('/received/image.png'); + expect(failure.message).toBe(message); + expect(failure.stack).toBe(stack); + } +}); diff --git a/private/react-native-fantom/runtime/__tests__/setup-test.js b/private/react-native-fantom/runtime/__tests__/setup-test.js new file mode 100644 index 000000000000..a16920c720de --- /dev/null +++ b/private/react-native-fantom/runtime/__tests__/setup-test.js @@ -0,0 +1,239 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const jestExpect = expect; +const jestRuntime = jest; +const globalNames = [ + 'describe', + 'fdescribe', + 'xdescribe', + 'it', + 'fit', + 'xit', + 'test', + 'xtest', + 'afterAll', + 'afterEach', + 'beforeAll', + 'beforeEach', + 'expect', + 'jest', + '$$RunTests$$', +]; +let descriptors: Map; + +jest.mock('../expect', () => () => {}); +jest.mock('../patchWeakRef', () => () => {}); +jest.mock( + '../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom', + () => ({ + __esModule: true, + default: { + validateEmptyMessageQueue: jest.fn(), + reportTestSuiteResultsJSON: jest.fn(), + }, + }), +); + +beforeEach(() => { + descriptors = new Map( + globalNames.map(name => [ + name, + Object.getOwnPropertyDescriptor(global, name), + ]), + ); + jestRuntime.resetModules(); +}); + +afterEach(() => { + for (const [name, descriptor] of descriptors) { + if (descriptor == null) { + delete global[name]; + } else { + Object.defineProperty(global, name, descriptor); + } + } +}); + +function results() { + const NativeFantom = + require('../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom').default; + let report; + jestRuntime + .spyOn(NativeFantom, 'reportTestSuiteResultsJSON') + .mockImplementation(json => { + report = JSON.parse(json); + }); + global.$$RunTests$$(); + return report?.testResults ?? []; +} + +it('runs teardown after a failed test before running the next test', () => { + const {registerTest} = require('../setup'); + const events = []; + registerTest( + () => { + beforeEach(() => { + events.push('setup'); + }); + afterEach(() => { + events.push('teardown'); + }); + it('fails', () => { + events.push('failed test'); + throw new Error('assertion failed'); + }); + it('passes', () => { + events.push('next test'); + }); + }, + {data: {}, updateSnapshot: 'none'}, + ); + const [failed, passed] = results(); + jestExpect(failed.status).toBe('failed'); + jestExpect(failed.failureMessages[0]).toContain('assertion failed'); + jestExpect(passed.status).toBe('passed'); + jestExpect(events).toEqual([ + 'setup', + 'failed test', + 'teardown', + 'setup', + 'next test', + 'teardown', + ]); +}); + +for (const nonErrorPosition of ['test', 'teardown']) { + it(`keeps error details aligned when ${nonErrorPosition} throws a non-Error value`, () => { + const {registerTest} = require('../setup'); + const error = new Error('ordinary error'); + const errors = + nonErrorPosition === 'test' + ? ['non-error failure', error] + : [error, 'non-error failure']; + registerTest( + () => { + afterEach(() => { + throw errors[1]; + }); + it(`fails with non-Error in ${nonErrorPosition}`, () => { + throw errors[0]; + }); + }, + {data: {}, updateSnapshot: 'none'}, + ); + const [failed] = results(); + const nonErrorIndex = nonErrorPosition === 'test' ? 0 : 1; + const errorIndex = 1 - nonErrorIndex; + jestExpect(failed.failureMessages.length).toBe(2); + jestExpect(failed.failureDetails.length).toBe(2); + jestExpect(failed.failureDetails[nonErrorIndex]).toBe(null); + jestExpect(failed.failureDetails[errorIndex].message).toBe( + 'ordinary error', + ); + jestExpect(failed.failureMessages[nonErrorIndex]).toContain( + 'non-error failure', + ); + jestExpect(failed.failureMessages[errorIndex]).toContain('ordinary error'); + }); +} + +it('runs teardown after a failed setup and retains both errors', () => { + const {registerTest} = require('../setup'); + registerTest( + () => { + beforeEach(() => { + throw new Error('setup failed'); + }); + afterEach(() => { + throw new Error('teardown failed'); + }); + it('does not run', () => { + throw new Error('test should not run'); + }); + }, + {data: {}, updateSnapshot: 'none'}, + ); + const [failed] = results(); + jestExpect(failed.status).toBe('failed'); + jestExpect(failed.failureDetails[0].message).toBe('setup failed'); + jestExpect(failed.failureDetails[1].message).toBe('teardown failed'); +}); + +it('preserves screenshot text errors alongside preview errors and teardown failures', () => { + const {registerTest} = require('../setup'); + const {ScreenshotSnapshotError} = require('../snapshotContext'); + registerTest( + () => { + afterEach(() => { + throw new Error('teardown failed'); + }); + it('fails screenshot assertion', () => { + throw new ScreenshotSnapshotError( + 'Expected old-hash, received new-hash', + 'Expected to match screenshot snapshot.', + ); + }); + }, + {data: {}, updateSnapshot: 'none'}, + ); + const [failed] = results(); + jestExpect(failed.failureMessages[0]).toContain('old-hash'); + jestExpect(failed.failureDetails[0].message).toContain('old-hash'); + jestExpect(failed.screenshotFailureMessages[0]).toContain( + 'Expected to match screenshot snapshot.', + ); + jestExpect(failed.screenshotFailureMessages[0]).not.toContain('old-hash'); + jestExpect(failed.screenshotFailureMessages[1]).toBe(null); +}); + +for (const screenshotPosition of ['test', 'teardown']) { + it(`keeps failure indexes aligned when the screenshot fails in ${screenshotPosition}`, () => { + const {registerTest} = require('../setup'); + const {ScreenshotSnapshotError} = require('../snapshotContext'); + const screenshotError = new ScreenshotSnapshotError( + 'Expected old-hash, received new-hash', + 'Expected to match screenshot snapshot.', + ); + const errors = + screenshotPosition === 'test' + ? [screenshotError, 'non-error failure'] + : ['non-error failure', screenshotError]; + registerTest( + () => { + afterEach(() => { + throw errors[1]; + }); + it(`fails with screenshot in ${screenshotPosition}`, () => { + throw errors[0]; + }); + }, + {data: {}, updateSnapshot: 'none'}, + ); + const [failed] = results(); + const screenshotIndex = screenshotPosition === 'test' ? 0 : 1; + const nonErrorIndex = 1 - screenshotIndex; + jestExpect(failed.failureDetails.length).toBe(2); + jestExpect(failed.failureDetails[nonErrorIndex]).toBe(null); + jestExpect(failed.failureDetails[screenshotIndex].message).toContain( + 'old-hash', + ); + jestExpect(failed.screenshotFailureMessages[screenshotIndex]).toContain( + 'Expected to match screenshot snapshot.', + ); + jestExpect(failed.screenshotFailureMessages[screenshotIndex]).not.toContain( + 'old-hash', + ); + jestExpect(failed.screenshotFailureMessages[nonErrorIndex]).toBe(null); + jestExpect(failed.failureMessages[nonErrorIndex]).toContain( + 'non-error failure', + ); + }); +} diff --git a/private/react-native-fantom/runtime/expect.js b/private/react-native-fantom/runtime/expect.js index dbbbdbcd8b05..8766187911d3 100644 --- a/private/react-native-fantom/runtime/expect.js +++ b/private/react-native-fantom/runtime/expect.js @@ -9,7 +9,7 @@ */ import {ensureMockFunction} from './mocks'; -import {snapshotContext} from './snapshotContext'; +import {ScreenshotSnapshotError, snapshotContext} from './snapshotContext'; import deepEqual from 'deep-equal'; import {diff} from 'jest-diff'; import {format, plugins} from 'pretty-format'; @@ -517,14 +517,27 @@ class Expect { ).blameToPreviousFrame(); } + const Screenshot = require('../src/Screenshot').default; + const screenshot = + this.#received instanceof Screenshot ? this.#received : undefined; const receivedValue = format(this.#received, { plugins: [plugins.ReactElement], }); try { - snapshotContext.toMatchSnapshot(receivedValue, expected); + snapshotContext.toMatchSnapshot(receivedValue, expected, screenshot); } catch (err) { - throw new ErrorWithCustomBlame(err.message).blameToPreviousFrame(); + const error = new ErrorWithCustomBlame( + err.message, + ).blameToPreviousFrame(); + if (err instanceof ScreenshotSnapshotError) { + err.stack = error.stack; + err.previewError.stack = new ErrorWithCustomBlame( + err.previewError.message, + ).blameToPreviousFrame().stack; + throw err; + } + throw error; } } diff --git a/private/react-native-fantom/runtime/setup.js b/private/react-native-fantom/runtime/setup.js index 91cbb2f897d9..7cb8859b4624 100644 --- a/private/react-native-fantom/runtime/setup.js +++ b/private/react-native-fantom/runtime/setup.js @@ -10,6 +10,7 @@ import type {CoverageMap} from '../runner/coverage/types.flow'; import type {BenchmarkResult} from '../src/Benchmark'; +import type {ScreenshotResult} from './ScreenshotContext'; import type { SnapshotConfig, TestInlineSnapshotResults, @@ -20,7 +21,12 @@ import {getConstants} from '../src/Constants'; import expect from './expect'; import {createMockFunction} from './mocks'; import patchWeakRef from './patchWeakRef'; -import {setupSnapshotConfig, snapshotContext} from './snapshotContext'; +import {screenshotContext} from './ScreenshotContext'; +import { + ScreenshotSnapshotError, + setupSnapshotConfig, + snapshotContext, +} from './snapshotContext'; export type TestCaseResult = { ancestorTitles: Array, @@ -29,10 +35,12 @@ export type TestCaseResult = { status: 'passed' | 'failed' | 'pending', duration: number, failureMessages: Array, - failureDetails: Array, + screenshotFailureMessages?: Array, + failureDetails: Array, numPassingAsserts: number, snapshotResults: TestSnapshotResults, inlineSnapshotResults: TestInlineSnapshotResults, + screenshotResults: Array, // location: string, }; @@ -323,46 +331,57 @@ function runSpec(spec: Spec): TestCaseResult { numPassingAsserts: 0, snapshotResults: {}, inlineSnapshotResults: [], + screenshotResults: [], }; if (!shouldRunSuite(spec)) { return result; } - let status: 'passed' | 'failed' | 'pending'; - let error: unknown; + const errors: Array = []; const start = Date.now(); snapshotContext.setTargetTest(result.fullName); + screenshotContext.setTargetTest(result.fullName); try { invokeHooks(spec.parentContext, 'beforeEachHooks'); spec.implementation(); - invokeHooks(spec.parentContext, 'afterEachHooks'); + } catch (e: unknown) { + errors.push(e); + } - status = 'passed'; + try { + invokeHooks(spec.parentContext, 'afterEachHooks'); } catch (e: unknown) { - error = e; - status = 'failed'; + errors.push(e); } - result.status = status; + result.status = errors.length === 0 ? 'passed' : 'failed'; result.duration = Date.now() - start; - if (status === 'failed' && error != null) { + for (const error of errors) { if (error instanceof Error) { - result.failureMessages = [error.stack ?? error.message ?? String(error)]; - result.failureDetails = [serializeError(error)]; + result.failureMessages.push( + error.stack ?? error.message ?? String(error), + ); + result.failureDetails.push(serializeError(error)); } else { - result.failureMessages = [`Non-error value thrown: ${String(error)}`]; - result.failureDetails = []; + result.failureMessages.push(`Non-error value thrown: ${String(error)}`); + result.failureDetails.push(null); } - } else { - result.failureMessages = []; - result.failureDetails = []; + } + + if (errors.some(error => error instanceof ScreenshotSnapshotError)) { + result.screenshotFailureMessages = errors.map(error => + error instanceof ScreenshotSnapshotError + ? error.getPreviewMessage() + : null, + ); } result.snapshotResults = snapshotContext.getSnapshotResults(); result.inlineSnapshotResults = snapshotContext.getInlineSnapshotResults(); + result.screenshotResults = screenshotContext.getResults(); return result; } diff --git a/private/react-native-fantom/runtime/snapshotContext.js b/private/react-native-fantom/runtime/snapshotContext.js index e89b54c80ba1..dbfa9ec5a77c 100644 --- a/private/react-native-fantom/runtime/snapshotContext.js +++ b/private/react-native-fantom/runtime/snapshotContext.js @@ -8,11 +8,16 @@ * @format */ +import type Screenshot from '../src/Screenshot'; +import type {ScreenshotConfig} from './ScreenshotContext'; + +import {screenshotContext} from './ScreenshotContext'; import {diff} from 'jest-diff'; export type SnapshotConfig = { updateSnapshot: 'all' | 'new' | 'none', data: {[key: string]: string}, + screenshots?: ScreenshotConfig, }; export type TestSnapshotResults = { @@ -32,8 +37,6 @@ export type InlineSnapshotResult = export type TestInlineSnapshotResults = Array; -const COMPARISON_EQUALS_STRING = 'Compared values have no visual difference.'; - const INDENTATION_REGEX: RegExp = /^([^\S\n]*)\S/m; let snapshotConfig: ?SnapshotConfig; @@ -94,57 +97,52 @@ class SnapshotContext { }; } - toMatchSnapshot(received: string, label: ?string): void { + toMatchSnapshot( + received: string, + label: ?string, + screenshot?: Screenshot, + ): void { const snapshotState = this.#snapshotState; if (snapshotState == null) { throw new Error( 'Snapshot state is not set, call `setTargetTest()` first', ); } - - const snapshotKey = `${snapshotState.testFullName}${ - label != null ? `: ${label}` : '' - } ${++snapshotState.callCount}`; - - if (snapshotConfig == null) { + const config = snapshotConfig; + if (config == null) { throw new Error( 'Snapshot config is not set. Did you forget to call `setupSnapshotConfig`?', ); } - - const updateSnapshot = snapshotConfig.updateSnapshot; - const snapshot = snapshotConfig.data[snapshotKey]; - - if (snapshot == null) { - snapshotState.snapshotResults[snapshotKey] = { - pass: false, - value: received, - }; - - if (updateSnapshot === 'none') { - throw new Error( - `Expected to have snapshot \`${snapshotKey}\` but it was not found.`, - ); - } - - return; + const key = `${snapshotState.testFullName}${label != null ? `: ${label}` : ''} ${++snapshotState.callCount}`; + const snapshot = config.data[key]; + const isNew = snapshot == null; + let pass = snapshot === received; + if (screenshot != null) { + pass = screenshotContext.record(screenshot, key, pass, isNew); } - - const result = diff(snapshot, received) ?? 'Failed to compare output'; - if (result !== COMPARISON_EQUALS_STRING) { - snapshotState.snapshotResults[snapshotKey] = { - pass: false, - value: received, - }; - - if (updateSnapshot !== 'all') { - throw new Error(`Expected to match snapshot.\n${result}`); - } - + snapshotState.snapshotResults[key] = pass + ? {pass: true} + : {pass: false, value: received}; + if ( + pass || + config.updateSnapshot === 'all' || + (isNew && config.updateSnapshot === 'new') + ) { return; } - - snapshotState.snapshotResults[snapshotKey] = {pass: true}; + const message = isNew + ? `Expected to have snapshot \`${key}\` but it was not found.` + : `Expected to match snapshot.\n${diff(snapshot, received) ?? 'Failed to compare output'}`; + if (screenshot != null) { + throw new ScreenshotSnapshotError( + `${message}\n${screenshotContext.getFailureMessage()}`, + isNew + ? `Expected to have screenshot snapshot \`${key}\` but it was not found.` + : 'Expected to match screenshot snapshot.', + ); + } + throw new Error(message); } toMatchInlineSnapshot( @@ -217,8 +215,22 @@ class SnapshotContext { } } +export class ScreenshotSnapshotError extends Error { + previewError: Error; + + constructor(message: string, previewMessage: string) { + super(message); + this.previewError = new Error(previewMessage); + } + + getPreviewMessage(): string { + return this.previewError.stack ?? this.previewError.message; + } +} + export const snapshotContext: SnapshotContext = new SnapshotContext(); export function setupSnapshotConfig(config: SnapshotConfig) { snapshotConfig = config; + screenshotContext.configure(config.screenshots); } diff --git a/private/react-native-fantom/src/Screenshot.js b/private/react-native-fantom/src/Screenshot.js new file mode 100644 index 000000000000..7f6ef71e9012 --- /dev/null +++ b/private/react-native-fantom/src/Screenshot.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +type NativeScreenshot = { + saveToFile(filePath: string): void, + isEqual(other: NativeScreenshot): boolean, + getHash(): string, +}; + +export default class Screenshot { + #nativeScreenshot: NativeScreenshot; + + constructor(nativeScreenshot: NativeScreenshot) { + this.#nativeScreenshot = nativeScreenshot; + } + + saveToFile(filePath: string): void { + this.#nativeScreenshot.saveToFile(filePath); + } + + isEqual(other: Screenshot): boolean { + return ( + other instanceof Screenshot && + this.#nativeScreenshot.isEqual(other.#nativeScreenshot) + ); + } + + toJSON(): {type: 'Screenshot', hash: string} { + return {type: 'Screenshot', hash: this.#nativeScreenshot.getHash()}; + } +} diff --git a/private/react-native-fantom/src/__tests__/FantomImage-itest.js b/private/react-native-fantom/src/__tests__/FantomImage-itest.js new file mode 100644 index 000000000000..bb80bfd9ccd6 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomImage-itest.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {Root, RootConfig} from '@react-native/fantom'; +import type {HostInstance, ScrollViewInstance} from 'react-native'; + +import FeedScreen from './fixtures/FeedScreen'; +import TravelScreen from './fixtures/TravelScreen'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef, useState} from 'react'; +import {ScrollView, Text, TextInput, View} from 'react-native'; + +component ScrollableInputs( + scrollRef: React.RefObject, + nameRef: React.RefObject, + notesRef: React.RefObject, + scrollHeight?: number, +) { + const [name, setName] = useState(''); + const [notes, setNotes] = useState(''); + + return ( + + Fixed heading + + + + + + + + ); +} + +let roots: Array; + +beforeEach(() => { + roots = []; +}); + +afterEach(() => { + for (const root of roots) { + root.destroy(); + } +}); + +function createRoot(config: RootConfig): Root { + const root = Fantom.createRoot(config); + roots.push(root); + return root; +} + +describe('Fantom root images', () => { + it('captures a screen, its update, and its scrolled viewport', () => { + const root = createRoot({ + devicePixelRatio: 1, + }); + const scrollRef = createRef(); + Fantom.runTask(() => root.render()); + const before = root.takeScreenshot(); + Fantom.runTask(() => + root.render(), + ); + const after = root.takeScreenshot(); + expect(after.isEqual(before)).toBe(false); + Fantom.scrollTo(scrollRef, {x: 0, y: 180}); + expect(root.takeScreenshot().isEqual(after)).toBe(false); + }); + + it('captures the mounted revision without flushing pending work', () => { + const root = createRoot({ + viewportWidth: 20, + viewportHeight: 10, + devicePixelRatio: 2, + }); + Fantom.runTask(() => + root.render(), + ); + Fantom.scheduleTask(() => + root.render(), + ); + const pending = root.takeScreenshot(); + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + Fantom.runWorkLoop(); + expect(root.takeScreenshot().isEqual(pending)).toBe(false); + }); + + it('captures the feed and restores its initial scroll position', () => { + const root = createRoot({ + viewportWidth: 402, + viewportHeight: 874, + devicePixelRatio: 1, + }); + const feedRef = createRef(); + + Fantom.runTask(() => root.render()); + const feed = nullthrows(feedRef.current); + const bounds = feed.getBoundingClientRect(); + expect(bounds.y).toBeGreaterThan(0); + expect(bounds.bottom).toBeLessThan(874); + expect(feed.scrollHeight).toBeGreaterThan(bounds.height + 360); + + const before = root.takeScreenshot(); + + Fantom.scrollTo(feedRef, {x: 0, y: 360}); + expect(feed.scrollTop).toBe(360); + expect(feed.getBoundingClientRect().y).toBe(bounds.y); + + expect(root.takeScreenshot().isEqual(before)).toBe(false); + + Fantom.scrollTo(feedRef, {x: 0, y: 0}); + expect(root.takeScreenshot().isEqual(before)).toBe(true); + }); + + for (const scrollHeight of [undefined, 400]) { + const viewport = + scrollHeight == null ? 'remaining screen' : 'inset viewport'; + it(`captures entered text and scrolling in the ${viewport}`, () => { + const root = createRoot({ + viewportWidth: 402, + viewportHeight: 874, + devicePixelRatio: 1, + }); + const scrollRef = createRef(); + const nameRef = createRef(); + const notesRef = createRef(); + + Fantom.runTask(() => + root.render( + , + ), + ); + expect(nullthrows(scrollRef.current).scrollTop).toBe(0); + const scrollBounds = nullthrows( + scrollRef.current, + ).getBoundingClientRect(); + expect(nullthrows(scrollRef.current).scrollHeight).toBeGreaterThan( + scrollBounds.height + 240, + ); + if (scrollHeight != null) { + expect(scrollBounds.height).toBe(scrollHeight); + expect(scrollBounds.x).toBe(20); + expect(scrollBounds.width).toBe(362); + expect(scrollBounds.y).toBeGreaterThan(0); + expect(scrollBounds.bottom).toBeLessThan(874); + } + + const before = root.takeScreenshot(); + + Fantom.dispatchNativeEvent(nameRef, 'change', { + text: 'Ada Lovelace', + eventCount: 1, + }); + const name = root.takeScreenshot(); + expect(name.isEqual(before)).toBe(false); + + Fantom.dispatchNativeEvent(notesRef, 'change', { + text: 'Build my first screen.\nExplore the learning links.', + eventCount: 1, + }); + const inputs = root.takeScreenshot(); + expect(inputs.isEqual(name)).toBe(false); + + const inputBounds = nullthrows(nameRef.current).getBoundingClientRect(); + Fantom.scrollTo(scrollRef, {x: 0, y: 240}); + expect(nullthrows(scrollRef.current).scrollTop).toBe(240); + expect(nullthrows(scrollRef.current).getBoundingClientRect().y).toBe( + scrollBounds.y, + ); + expect(nullthrows(nameRef.current).getBoundingClientRect().y).toBe( + inputBounds.y, + ); + + expect(root.takeScreenshot().isEqual(inputs)).toBe(false); + + Fantom.scrollTo(scrollRef, {x: 0, y: 0}); + expect(root.takeScreenshot().isEqual(inputs)).toBe(true); + }); + } + + it('rejects screenshot capture after root destruction', () => { + const root = Fantom.createRoot({ + viewportWidth: 8, + viewportHeight: 4, + devicePixelRatio: 1, + }); + Fantom.runTask(() => root.render()); + root.destroy(); + expect(() => root.takeScreenshot()).toThrow( + 'Cannot capture a screenshot of a destroyed root.', + ); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/FantomRendering-itest.js b/private/react-native-fantom/src/__tests__/FantomRendering-itest.js new file mode 100644 index 000000000000..2d0aa3a66b43 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomRendering-itest.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {Root} from '@react-native/fantom'; + +import { + AbsolutePositionScene, + OverflowScene, + StackingScene, + TransformScene, +} from './fixtures/RenderingEdgeCases'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; + +let root: Root; + +beforeEach(() => { + root = Fantom.createRoot(); +}); + +afterEach(() => { + root.destroy(); +}); + +describe('Fantom rendering edge cases', () => { + it('preserves sibling z-index order and nested stacking after updates', () => { + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot('updated stacking'); + }); + + it('clips overflowing descendants to rounded bounds without clipping siblings', () => { + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot('visible overflow'); + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot('hidden overflow'); + }); + + it('positions absolute children within bordered parents without affecting flow', () => { + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + }); + + it('renders affine transforms and skips singular siblings at fractional coordinates', () => { + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/FantomScreenSnapshots-itest.js b/private/react-native-fantom/src/__tests__/FantomScreenSnapshots-itest.js new file mode 100644 index 000000000000..b2949d443b3c --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomScreenSnapshots-itest.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {Root, RootConfig} from '@react-native/fantom'; +import type {HostInstance, ScrollViewInstance} from 'react-native'; + +import RuntimeVersion from '../../../../packages/new-app-screen/src/RuntimeVersion'; +import FeedScreen from './fixtures/FeedScreen'; +import TravelScreen from './fixtures/TravelScreen'; +import * as Fantom from '@react-native/fantom'; +import {NewAppScreen} from '@react-native/new-app-screen'; +import * as React from 'react'; +import {createRef} from 'react'; + +const rootConfig: RootConfig = { + viewportWidth: 402, + viewportHeight: 874, + devicePixelRatio: 3, +}; + +describe('Fantom screen snapshots', () => { + let root: Root; + const hermesVersion = RuntimeVersion.hermes; + + beforeEach(() => { + RuntimeVersion.hermes = ''; + root = Fantom.createRoot(rootConfig); + }); + + afterEach(() => { + RuntimeVersion.hermes = hermesVersion; + root.destroy(); + }); + + it('captures the default New App Screen', () => { + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + }); + + it('captures the travel screen, entered text, and scrolling', () => { + const scrollRef = createRef(); + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + Fantom.runTask(() => + root.render(), + ); + expect(root.takeScreenshot()).toMatchSnapshot('entered text'); + Fantom.scrollTo(scrollRef, {x: 0, y: 180}); + expect(root.takeScreenshot()).toMatchSnapshot('scrolled'); + }); + + it('captures the feed and its scrolled viewport', () => { + const feedRef = createRef(); + Fantom.runTask(() => root.render()); + expect(root.takeScreenshot()).toMatchSnapshot(); + Fantom.scrollTo(feedRef, {x: 0, y: 360}); + expect(root.takeScreenshot()).toMatchSnapshot('scrolled'); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/FantomScreenshot-itest.js b/private/react-native-fantom/src/__tests__/FantomScreenshot-itest.js new file mode 100644 index 000000000000..aa6aee5f6260 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomScreenshot-itest.js @@ -0,0 +1,190 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {Root, RootConfig} from '@react-native/fantom'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Image, Text, TextInput, View} from 'react-native'; + +const rootConfig: RootConfig = { + viewportWidth: 8, + viewportHeight: 4, + devicePixelRatio: 1, +}; + +let roots: Array; + +beforeEach(() => { + roots = []; +}); + +afterEach(() => { + for (const root of roots) { + root.destroy(); + } +}); + +function createRoot(config: RootConfig): Root { + const root = Fantom.createRoot(config); + roots.push(root); + return root; +} + +describe('Fantom screenshot snapshots', () => { + it('matches an inline screenshot hash', () => { + const root = createRoot(rootConfig); + Fantom.runTask(() => + root.render(), + ); + expect(root.takeScreenshot()).toMatchInlineSnapshot(` + Object { + "hash": "7ce176afc63d276869ec36bb0ad16f57098496278cfa87967206be51e2752102", + "type": "Screenshot", + } + `); + }); + + for (const devicePixelRatio of [3, 1, 1.5, 2]) { + const title = + 'renders gray text, translucent layers and a resized image consistently'; + it( + devicePixelRatio === 3 + ? title + : `${title} at pixel ratio ${devicePixelRatio}`, + () => { + const root = createRoot({ + viewportWidth: 160, + viewportHeight: 140, + devicePixelRatio, + }); + Fantom.runTask(() => + root.render( + + + Gray text 123 + + + + + + , + ), + ); + expect(root.takeScreenshot()).toMatchSnapshot(); + }, + ); + } + + it('renders scaled text and inputs like equivalent explicit font sizes', () => { + const config: RootConfig = { + viewportWidth: 256, + viewportHeight: 200, + devicePixelRatio: 1, + }; + const unscaled = createRoot(config); + const scaled = createRoot({...config, fontScale: 2}); + const elements = (fontSize: number, lineHeight: number) => ( + + Fantom + + + + ); + Fantom.runTask(() => { + unscaled.render(elements(16, 24)); + scaled.render(elements(16, 24)); + }); + const screenshot = scaled.takeScreenshot(); + const unscaledScreenshot = unscaled.takeScreenshot(); + expect(unscaledScreenshot).toMatchSnapshot('fontScale 1'); + expect(screenshot).toMatchSnapshot('fontScale 2'); + expect(screenshot.isEqual(unscaledScreenshot)).toBe(false); + Fantom.runTask(() => unscaled.render(elements(32, 48))); + expect(screenshot.isEqual(unscaled.takeScreenshot())).toBe(true); + }); + + it('captures a mounted root and its update', () => { + const root = createRoot(rootConfig); + Fantom.runTask(() => + root.render(), + ); + const screenshot = root.takeScreenshot(); + expect(screenshot).toMatchSnapshot(); + Fantom.runTask(() => + root.render(), + ); + expect(root.takeScreenshot()).toMatchSnapshot('updated'); + }); + + it('uses the same snapshot numbering for screenshots and other values', () => { + const root = createRoot(rootConfig); + Fantom.runTask(() => + root.render(), + ); + expect('before screenshot').toMatchSnapshot(); + expect(root.takeScreenshot()).toMatchSnapshot('screen'); + expect('after screenshot').toMatchSnapshot(); + }); + + it('retains pixels across root updates and destruction and compares dimensions', () => { + const root = Fantom.createRoot(rootConfig); + const smaller = Fantom.createRoot({...rootConfig, viewportWidth: 4}); + Fantom.runTask(() => + root.render(), + ); + const red = root.takeScreenshot(); + const sameRed = root.takeScreenshot(); + expect(red.isEqual(sameRed)).toBe(true); + expect(sameRed.isEqual(red)).toBe(true); + expect(red.toJSON()).toEqual(sameRed.toJSON()); + Fantom.runTask(() => + root.render(), + ); + const blue = root.takeScreenshot(); + expect(red.isEqual(blue)).toBe(false); + expect(blue.isEqual(red)).toBe(false); + expect(red.toJSON()).not.toEqual(blue.toJSON()); + Fantom.runTask(() => + smaller.render(), + ); + expect(red.isEqual(smaller.takeScreenshot())).toBe(false); + root.destroy(); + smaller.destroy(); + expect(red.isEqual(sameRed)).toBe(true); + expect(red).toMatchSnapshot('retained red'); + expect(blue).toMatchSnapshot('retained blue'); + expect(red.isEqual(blue)).toBe(false); + expect(() => root.takeScreenshot()).toThrow( + 'Cannot capture a screenshot of a destroyed root.', + ); + expect(() => expect(red).not.toMatchSnapshot()).toThrow( + 'Snapshot matchers cannot be used with not.', + ); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js b/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js new file mode 100644 index 000000000000..4c76030ec5e4 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js @@ -0,0 +1,197 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import NativeFantom from '../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import {createRef} from 'react'; +import {Text, TextInput, View} from 'react-native'; + +describe('Fantom text measurement', () => { + it('reports invalid font files', () => { + expect(() => NativeFantom.registerFont('/missing/fantom-font.ttf')).toThrow( + 'Cannot register Fantom font: /missing/fantom-font.ttf', + ); + }); + + it('measures intrinsic text and input sizes', () => { + const root = Fantom.createRoot(); + const textRef = createRef(); + const inputRef = createRef(); + const elements = ( + + Hello Fantom + + + ); + Fantom.runTask(() => root.render(elements)); + for (const ref of [textRef, inputRef]) { + const bounds = nullthrows(ref.current).getBoundingClientRect(); + expect(bounds.width).toBeGreaterThan(40); + expect(bounds.height).toBeGreaterThan(10); + } + root.destroy(); + }); + + it('wraps text with a constrained width', () => { + const root = Fantom.createRoot(); + const singleLineRef = createRef(); + const wrappedRef = createRef(); + const text = 'Fantom measures text across multiple lines.'; + Fantom.runTask(() => + root.render( + + {text} + + {text} + + , + ), + ); + const singleLine = nullthrows( + singleLineRef.current, + ).getBoundingClientRect(); + const wrapped = nullthrows(wrappedRef.current).getBoundingClientRect(); + expect(wrapped.width).toBe(80); + expect(wrapped.height).toBeGreaterThan(singleLine.height); + root.destroy(); + }); + + it('scales text, input values, placeholders, and explicit line heights', () => { + const sizes: Array> = []; + for (const fontScale of [1, 2]) { + const root = Fantom.createRoot({fontScale, devicePixelRatio: 4}); + const refs = Array.from({length: 4}, () => createRef()); + const style = {fontSize: 16, lineHeight: 24}; + Fantom.runTask(() => + root.render( + + + Fantom + + + + + , + ), + ); + sizes.push( + refs.map(ref => { + const {width, height} = nullthrows( + ref.current, + ).getBoundingClientRect(); + return {width, height}; + }), + ); + root.destroy(); + } + sizes[0].forEach((size, index) => { + expect(size.width).toBe(61); + expect(size.height).toBe(index === 3 ? 48 : 24); + expect(sizes[1][index].width).toBe(121.75); + expect(sizes[1][index].height).toBe(index === 3 ? 96 : 48); + }); + }); + + it('respects scaling opt-outs and caps for text and inputs', () => { + const root = Fantom.createRoot({fontScale: 2, devicePixelRatio: 4}); + const refs = Array.from({length: 6}, () => createRef()); + const style = {fontSize: 16, lineHeight: 24}; + Fantom.runTask(() => + root.render( + + + Fantom + + + Fantom + + + Fantom + + + + + , + ), + ); + const sizes = refs.map(ref => + nullthrows(ref.current).getBoundingClientRect(), + ); + for (const offset of [0, 3]) { + expect(sizes[offset].height).toBe(24); + expect(sizes[offset + 1].height).toBe(36); + expect(sizes[offset + 2].height).toBe(48); + expect(sizes[offset + 1].width).toBeCloseTo(sizes[offset].width * 1.5, 0); + expect(sizes[offset + 2].width).toBeCloseTo(sizes[offset].width * 2, 0); + } + root.destroy(); + }); + + it('keeps native font scale per root when global metrics change', () => { + const first = Fantom.createRoot({fontScale: 1}); + const firstRef = createRef(); + const secondRef = createRef(); + const style = {width: 120, fontSize: 16, lineHeight: 24}; + const text = 'Fantom measures text across multiple lines.'; + Fantom.runTask(() => + first.render( + + {text} + , + ), + ); + const before = nullthrows(firstRef.current).getBoundingClientRect(); + const second = Fantom.createRoot({fontScale: 2}); + Fantom.runTask(() => { + second.render( + + {text} + , + ); + first.render( + + {text}! + , + ); + }); + const after = nullthrows(firstRef.current).getBoundingClientRect(); + const scaled = nullthrows(secondRef.current).getBoundingClientRect(); + expect(after.height).toBe(before.height); + expect(scaled.width).toBe(before.width); + expect(scaled.height).toBeGreaterThan(before.height * 2); + first.destroy(); + second.destroy(); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-46ec444f-c400ac01.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-46ec444f-c400ac01.png new file mode 100644 index 000000000000..dee9dcd11f5f Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-46ec444f-c400ac01.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-c8cb4e7-4d1a8bcc.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-c8cb4e7-4d1a8bcc.png new file mode 100644 index 000000000000..c6ea5eb9699d Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_clips_overflowing_descendants_to_rounded_bounds_without_clipping_sibling-c8cb4e7-4d1a8bcc.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_positions_absolute_children_within_bordered_parents_without_affecting_fl-c6ab977f-e6c6a7e3.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_positions_absolute_children_within_bordered_parents_without_affecting_fl-c6ab977f-e6c6a7e3.png new file mode 100644 index 000000000000..ad195f2ead77 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_positions_absolute_children_within_bordered_parents_without_affecting_fl-c6ab977f-e6c6a7e3.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates_1-3a143b48-fc012bee.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates_1-3a143b48-fc012bee.png new file mode 100644 index 000000000000..952bee709738 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates_1-3a143b48-fc012bee.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates__updat-86e82409-b38e5e97.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates__updat-86e82409-b38e5e97.png new file mode 100644 index 000000000000..37ff9c7e5912 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_preserves_sibling_z-index_order_and_nested_stacking_after_updates__updat-86e82409-b38e5e97.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_renders_affine_transforms_and_skips_singular_siblings_at_fractional_coor-5663aa1e-9691f54d.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_renders_affine_transforms_and_skips_singular_siblings_at_fractional_coor-5663aa1e-9691f54d.png new file mode 100644 index 000000000000..1855c204300e Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.screenshots/Fantom_rendering_edge_cases_renders_affine_transforms_and_skips_singular_siblings_at_fractional_coor-5663aa1e-9691f54d.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.snap b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.snap new file mode 100644 index 000000000000..1006e3bcf17a --- /dev/null +++ b/private/react-native-fantom/src/__tests__/__snapshots__/FantomRendering-itest.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Fantom rendering edge cases clips overflowing descendants to rounded bounds without clipping siblings: hidden overflow 2`] = ` +Object { + "hash": "d96b7c47c122e6fa6e7baa6176704599759933d9dcadc45eacf2151600428a9e", + "type": "Screenshot", +} +`; + +exports[`Fantom rendering edge cases clips overflowing descendants to rounded bounds without clipping siblings: visible overflow 1`] = ` +Object { + "hash": "d1abab448c9de50610e7bb36a4b0d3a44ab91693a67c518c0f907de1c0d86533", + "type": "Screenshot", +} +`; + +exports[`Fantom rendering edge cases positions absolute children within bordered parents without affecting flow 1`] = ` +Object { + "hash": "97a42fd0b859db84a7e455b5e9d4f9e239be2b03941cb7dc0f8a515988005fbc", + "type": "Screenshot", +} +`; + +exports[`Fantom rendering edge cases preserves sibling z-index order and nested stacking after updates 1`] = ` +Object { + "hash": "02d66a4bc95ebd7d9d6d6ec4ccf666ac88fda34aa5b5d1ec4dfbf3eb8e02db52", + "type": "Screenshot", +} +`; + +exports[`Fantom rendering edge cases preserves sibling z-index order and nested stacking after updates: updated stacking 2`] = ` +Object { + "hash": "508558e0f125fdad08b6eb73d7977836b389def8f63854edb6f063ffa17c2a75", + "type": "Screenshot", +} +`; + +exports[`Fantom rendering edge cases renders affine transforms and skips singular siblings at fractional coordinates 1`] = ` +Object { + "hash": "a2e0e843ad978f06363351b91a5140658fb69e8d97049766cbf330959d580daf", + "type": "Screenshot", +} +`; diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_default_New_App_Screen_1-be80b08-e2d37810.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_default_New_App_Screen_1-be80b08-e2d37810.png new file mode 100644 index 000000000000..726d551fe3e6 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_default_New_App_Screen_1-be80b08-e2d37810.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport_1-7d02926c-40d84b27.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport_1-7d02926c-40d84b27.png new file mode 100644 index 000000000000..fa1ac63f5b78 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport_1-7d02926c-40d84b27.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport__scrolled_2-26d16dd1-857e230.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport__scrolled_2-26d16dd1-857e230.png new file mode 100644 index 000000000000..5d1529b9ccef Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_feed_and_its_scrolled_viewport__scrolled_2-26d16dd1-857e230.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling_1-c7cc76f7-dec535b.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling_1-c7cc76f7-dec535b.png new file mode 100644 index 000000000000..9266b37dc97b Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling_1-c7cc76f7-dec535b.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__entered_text_2-8697fbe5-4d63854c.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__entered_text_2-8697fbe5-4d63854c.png new file mode 100644 index 000000000000..2ebd170ba156 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__entered_text_2-8697fbe5-4d63854c.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__scrolled_3-e34fba52-c255cd01.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__scrolled_3-e34fba52-c255cd01.png new file mode 100644 index 000000000000..b731b2068d36 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.screenshots/Fantom_screen_snapshots_captures_the_travel_screen__entered_text__and_scrolling__scrolled_3-e34fba52-c255cd01.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.snap b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.snap new file mode 100644 index 000000000000..b55f4182ac74 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenSnapshots-itest.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Fantom screen snapshots captures the default New App Screen 1`] = ` +Object { + "hash": "5597b8c0ba6b4d0b508b6cfb6b311b4ed637534357438c68e6515ec27144118c", + "type": "Screenshot", +} +`; + +exports[`Fantom screen snapshots captures the feed and its scrolled viewport 1`] = ` +Object { + "hash": "a71fd606e25d99e9afc33d276138ae2db1d42352ec0995d23bdaefab811eba17", + "type": "Screenshot", +} +`; + +exports[`Fantom screen snapshots captures the feed and its scrolled viewport: scrolled 2`] = ` +Object { + "hash": "033cb89f80f8db1e3c408dded78240ce04f006b48e239220de330d43e4a61ce9", + "type": "Screenshot", +} +`; + +exports[`Fantom screen snapshots captures the travel screen, entered text, and scrolling 1`] = ` +Object { + "hash": "e0b9e388c8a46c11c7ed39be08f1a076544832b74e6b784f5c4f271fe25867de", + "type": "Screenshot", +} +`; + +exports[`Fantom screen snapshots captures the travel screen, entered text, and scrolling: entered text 2`] = ` +Object { + "hash": "58c4eff04819a3ae95cba4a78c488bae92a28ea17692564877920f5333b7d28f", + "type": "Screenshot", +} +`; + +exports[`Fantom screen snapshots captures the travel screen, entered text, and scrolling: scrolled 3`] = ` +Object { + "hash": "88171d2631826041e43b4936215241ef648c0b7c741b2dcbb302d21af6fe646f", + "type": "Screenshot", +} +`; diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update_1-494e02a7-4443fa4d.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update_1-494e02a7-4443fa4d.png new file mode 100644 index 000000000000..7f0c8ff1226c Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update_1-494e02a7-4443fa4d.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update__updated_2-5f9f2516-5ff04b44.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update__updated_2-5f9f2516-5ff04b44.png new file mode 100644 index 000000000000..04e6063bb2c2 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_captures_a_mounted_root_and_its_update__updated_2-5f9f2516-5ff04b44.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_1-cc3adee6-aad411c.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_1-cc3adee6-aad411c.png new file mode 100644 index 000000000000..a19187eeee0d Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_1-cc3adee6-aad411c.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-212ca480-68d9409c.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-212ca480-68d9409c.png new file mode 100644 index 000000000000..668eec6f446b Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-212ca480-68d9409c.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf163-e7947093.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf163-e7947093.png new file mode 100644 index 000000000000..a59d1d06eb26 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf163-e7947093.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf524-e79475ec.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf524-e79475ec.png new file mode 100644 index 000000000000..ed8c55017df8 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_gray_text__translucent_layers_and_a_resized_image_consistently_a-d49cf524-e79475ec.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f334712f-dc85824e.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f334712f-dc85824e.png new file mode 100644 index 000000000000..e7914cec74be Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f334712f-dc85824e.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f33474f1-dc8587a8.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f33474f1-dc8587a8.png new file mode 100644 index 000000000000..f14c1722207c Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_renders_scaled_text_and_inputs_like_equivalent_explicit_font_sizes__font-f33474f1-dc8587a8.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-aae099ae-82943b69.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-aae099ae-82943b69.png new file mode 100644 index 000000000000..04e6063bb2c2 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-aae099ae-82943b69.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-b3cc79e9-cdf14636.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-b3cc79e9-cdf14636.png new file mode 100644 index 000000000000..7f0c8ff1226c Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_retains_pixels_across_root_updates_and_destruction_and_compares_dimensio-b3cc79e9-cdf14636.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_uses_the_same_snapshot_numbering_for_screenshots_and_other_values__scree-8cb2723d-4b555e09.png b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_uses_the_same_snapshot_numbering_for_screenshots_and_other_values__scree-8cb2723d-4b555e09.png new file mode 100644 index 000000000000..7f0c8ff1226c Binary files /dev/null and b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.screenshots/Fantom_screenshot_snapshots_uses_the_same_snapshot_numbering_for_screenshots_and_other_values__scree-8cb2723d-4b555e09.png differ diff --git a/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.snap b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.snap new file mode 100644 index 000000000000..054de6882459 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/__snapshots__/FantomScreenshot-itest.js.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Fantom screenshot snapshots captures a mounted root and its update 1`] = ` +Object { + "hash": "7ce176afc63d276869ec36bb0ad16f57098496278cfa87967206be51e2752102", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots captures a mounted root and its update: updated 2`] = ` +Object { + "hash": "6776b08d89b43a18000707a6c428ff070d3416f812e7841862688ce23de01eba", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders gray text, translucent layers and a resized image consistently 1`] = ` +Object { + "hash": "a8cc60158a96a1cbeb3fcc00440de4873c49088ce57742f4fa2d0cada28879db", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders gray text, translucent layers and a resized image consistently at pixel ratio 1 1`] = ` +Object { + "hash": "730d828dd3fdb645695a9013cc7fe33521d447922aa6e370808d74d675c6447e", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders gray text, translucent layers and a resized image consistently at pixel ratio 1.5 1`] = ` +Object { + "hash": "ca9a34175147ddee02d02c2ab46c9515c8d7205650d437e837ac2ea50d7b1af4", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders gray text, translucent layers and a resized image consistently at pixel ratio 2 1`] = ` +Object { + "hash": "f4c961ba63f504075bfa5dc72a33f363b1ae15ec39551ea6c7078f819f508b40", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders scaled text and inputs like equivalent explicit font sizes: fontScale 1 1`] = ` +Object { + "hash": "51f11368745b4752dbbe86dcfa8006f5a90c6b4532edc5459be6c2dffde65164", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots renders scaled text and inputs like equivalent explicit font sizes: fontScale 2 2`] = ` +Object { + "hash": "d6787d29529adbc2b9a7f77e30d28dfbd301d6b7a7d7662aebe528b6852dcf5d", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots retains pixels across root updates and destruction and compares dimensions: retained blue 2`] = ` +Object { + "hash": "6776b08d89b43a18000707a6c428ff070d3416f812e7841862688ce23de01eba", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots retains pixels across root updates and destruction and compares dimensions: retained red 1`] = ` +Object { + "hash": "7ce176afc63d276869ec36bb0ad16f57098496278cfa87967206be51e2752102", + "type": "Screenshot", +} +`; + +exports[`Fantom screenshot snapshots uses the same snapshot numbering for screenshots and other values 1`] = `"before screenshot"`; + +exports[`Fantom screenshot snapshots uses the same snapshot numbering for screenshots and other values 3`] = `"after screenshot"`; + +exports[`Fantom screenshot snapshots uses the same snapshot numbering for screenshots and other values: screen 2`] = ` +Object { + "hash": "7ce176afc63d276869ec36bb0ad16f57098496278cfa87967206be51e2752102", + "type": "Screenshot", +} +`; diff --git a/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js b/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js new file mode 100644 index 000000000000..195614dd82d6 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import PixelRatio from '../../../../packages/react-native/Libraries/Utilities/PixelRatio'; +import {AssetRegistry} from '../../../../packages/react-native/src/private/assets/AssetRegistry'; +import assetPlugin from '../../config/assetPlugin'; +import {Image} from 'react-native'; + +const NativeFantom = + require('../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom').default; +const setUpAssetSourceResolver = require('../setUpAssetSourceResolver').default; + +const resolveAssetSource = Image.resolveAssetSource; + +jest.mock( + '../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom', + () => ({__esModule: true, default: {registerAsset: jest.fn()}}), +); + +const asset = { + __packager_asset: true, + fileSystemLocation: '/fixtures', + httpServerLocation: '/assets/fixtures', + width: 12, + height: 7, + scales: [1, 2, 3], + files: [ + '/fixtures/logo.png', + '/fixtures/logo@2x.android.png', + '/fixtures/logo@3x.png', + ], + hash: 'test', + name: 'logo', + type: 'png', + resolver: undefined, +}; + +beforeEach(() => { + jest.clearAllMocks(); + resolveAssetSource.setCustomSourceTransformer(() => null); + setUpAssetSourceResolver(); + jest.spyOn(PixelRatio, 'get').mockReturnValue(2); +}); + +afterEach(() => { + jest.restoreAllMocks(); + resolveAssetSource.setCustomSourceTransformer(() => null); +}); + +it.each([ + [1, 1, '/fixtures/logo.png'], + [1.5, 2, '/fixtures/logo@2x.android.png'], + [4, 3, '/fixtures/logo@3x.png'], +])( + 'resolves the bundled file at device scale %s', + (deviceScale, scale, file) => { + jest.spyOn(PixelRatio, 'get').mockReturnValue(deviceScale); + const id = AssetRegistry.registerAsset(assetPlugin(asset)); + const source = resolveAssetSource(id); + expect(source?.scale).toBe(scale); + expect(source).toEqual( + resolveAssetSource(AssetRegistry.registerAsset(asset)), + ); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith(source?.uri, file); + }, +); + +it('distinguishes assets with the same name in different directories', () => { + const first = AssetRegistry.registerAsset(assetPlugin(asset)); + const second = AssetRegistry.registerAsset( + assetPlugin({ + ...asset, + httpServerLocation: '/assets/other', + files: ['/other/logo.png'], + scales: [1], + }), + ); + const firstUri = resolveAssetSource(first)?.uri; + const secondUri = resolveAssetSource(second)?.uri; + expect(firstUri).not.toBe(secondUri); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith( + firstUri, + '/fixtures/logo@2x.android.png', + ); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith( + secondUri, + '/other/logo.png', + ); +}); + +it('reports a missing file for the selected scale', () => { + const id = AssetRegistry.registerAsset( + assetPlugin({...asset, files: ['/fixtures/logo.png']}), + ); + expect(() => resolveAssetSource(id)).toThrow( + 'Fantom could not find a bundled file for logo.png at scale 2.', + ); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('reports assets with no scales', () => { + const id = AssetRegistry.registerAsset(assetPlugin({...asset, scales: []})); + expect(() => resolveAssetSource(id)).toThrow( + 'Fantom could not find a bundled file for logo.png at scale 1.', + ); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('preserves explicit image sources', () => { + const source = {uri: '/images/logo.png', width: 12, height: 7}; + expect(resolveAssetSource(source)).toBe(source); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('uses normal resolution for assets without Fantom metadata', () => { + const id = AssetRegistry.registerAsset(asset); + resolveAssetSource.setCustomSourceTransformer(() => null); + const expected = resolveAssetSource(id); + setUpAssetSourceResolver(); + expect(resolveAssetSource(id)).toEqual(expected); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('respects an existing custom source transformer', () => { + resolveAssetSource.setCustomSourceTransformer(resolver => + resolver.fromSource('custom-source'), + ); + setUpAssetSourceResolver(); + const id = AssetRegistry.registerAsset(assetPlugin(asset)); + expect(resolveAssetSource(id)?.uri).toBe('custom-source'); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); diff --git a/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js b/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js new file mode 100644 index 000000000000..7f2bee97025b --- /dev/null +++ b/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js @@ -0,0 +1,95 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Text, TextInput, View} from 'react-native'; + +type Kind = 'short text' | 'wrapped text' | 'input' | 'multiline input'; +type BenchmarkCase = { + kind: Kind, + count: number, +}; + +const kinds: Array = [ + 'short text', + 'wrapped text', + 'input', + 'multiline input', +]; +const cases: Array = []; +for (const kind of kinds) { + for (const count of [100, 1000]) { + cases.push({kind, count}); + } +} + +const paragraph = + 'Fantom measures text using the same font metrics as its screenshot painter. This paragraph wraps across several lines without an explicit height.'; + +function createElements(kind: Kind, count: number): React.MixedElement { + const createElement = { + 'short text': (i: number) => {`Text instance ${i}`}, + 'wrapped text': (i: number) => ( + {`${paragraph} ${i}`} + ), + input: (i: number) => , + 'multiline input': (i: number) => ( + + ), + }[kind]; + return ( + + {Array.from({length: count}, (_, i) => createElement(i))} + + ); +} + +let root; +let elements: React.MixedElement; + +Fantom.unstable_benchmark + .suite('Text measurement') + .test.each( + cases, + ({kind, count}) => `render ${count} ${kind} components`, + () => { + Fantom.runTask(() => root.render(elements)); + }, + ({kind, count}) => ({ + beforeAll: () => { + elements = createElements(kind, count); + }, + beforeEach: () => { + root = Fantom.createRoot(); + }, + afterEach: () => { + root.destroy(); + }, + }), + ) + .test( + 'create root', + () => { + root = Fantom.createRoot(); + }, + { + afterEach: () => { + root.destroy(); + }, + }, + ); diff --git a/private/react-native-fantom/src/__tests__/fixtures/FeedScreen.js b/private/react-native-fantom/src/__tests__/fixtures/FeedScreen.js new file mode 100644 index 000000000000..1deddaadbb22 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/fixtures/FeedScreen.js @@ -0,0 +1,593 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {HostInstance} from 'react-native'; + +import * as React from 'react'; +import { + Image, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +const ASSETS = { + bunny: require('../../../../../packages/rn-tester/js/assets/bunny.png'), + ducky: require('../../../../../packages/rn-tester/js/assets/rubber-ducky.png'), + flowers: require('../../../../../packages/rn-tester/js/assets/flowers.png'), + hawk: require('../../../../../packages/rn-tester/js/assets/hawk.png'), + home: require('../../../../../packages/rn-tester/js/assets/bottom-nav-components-icon-light.png'), + hotdog: require('./feed-hotdog.png'), + like: require('../../../../../packages/rn-tester/js/assets/like.png'), + search: require('../../../../../packages/rn-tester/js/assets/search-icon.png'), + trees: require('./feed-trees.png'), +}; + +const STORIES = [ + {name: 'Your story', avatar: ASSETS.bunny, isOwn: true}, + {name: 'hawk.eye', avatar: ASSETS.hawk, isOwn: false}, + {name: 'ducky', avatar: ASSETS.ducky, isOwn: false}, + {name: 'flower_power', avatar: ASSETS.flowers, isOwn: false}, + {name: 'hotdog_lover', avatar: ASSETS.hotdog, isOwn: false}, +]; + +const POSTS = [ + { + author: 'Hawk Eye', + avatar: ASSETS.hawk, + location: 'Muir Woods', + time: '2h', + text: 'Spent the whole morning among the redwoods. The light this time of the year is unreal!', + tags: ['nature', 'weekend'], + image: ASSETS.trees, + likedBy: 'ducky', + likes: 1204, + comments: 87, + }, + { + author: 'Rubber Ducky', + avatar: ASSETS.ducky, + location: 'Fisherman’s Wharf', + time: '5h', + text: 'Found the best hot dog in town. Not sharing the address though.', + tags: ['food'], + image: ASSETS.hotdog, + likedBy: 'bunny', + likes: 342, + comments: 12, + }, +]; + +function formatCount(count: number): string { + return String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +component StatusBar() { + return ( + + 9:41 + + {[6, 9, 12, 15].map(height => ( + + ))} + + + + + + ); +} + +component Header() { + return ( + + Feed + + ♥ + + 3 + + + + ); +} + +component SearchBar() { + return ( + + + + + ); +} + +component Stories() { + return ( + + {STORIES.map(story => ( + + + + + {story.isOwn ? ( + + + + + ) : null} + + {story.name} + + + ))} + + ); +} + +component Post(post: (typeof POSTS)[number]) { + return ( + + + + + + {post.author} ✓ + + + {post.location} · {post.time} + + + ••• + + + {post.text} + {post.tags.map(tag => ( + + {' #' + tag} + + ))} + + + + + + {formatCount(post.likes)} + + + + {post.comments} + + + Share + + + + Liked by {post.likedBy} and{' '} + {formatCount(post.likes - 1)} others + + + ); +} + +component TabBar() { + return ( + + + + Home + + + + Explore + + + + + + + ♡ + Activity + + + + Profile + + + + ); +} + +export default component FeedScreen(feedRef?: React.RefSetter) { + return ( + + +
+ + + + {POSTS.map(post => ( + + ))} + + + + ); +} + +const PRIMARY_COLOR = '#1877F2'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#F0F2F5', + }, + statusBar: { + height: 44, + paddingHorizontal: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: 'white', + }, + statusBarTime: { + fontSize: 16, + fontWeight: '600', + }, + statusBarIcons: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 2, + }, + signalBar: { + width: 3, + borderRadius: 1, + backgroundColor: 'black', + }, + battery: { + marginLeft: 6, + width: 24, + height: 12, + padding: 1.5, + borderRadius: 3.5, + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.4)', + }, + batteryLevel: { + width: '80%', + flex: 1, + borderRadius: 1.5, + backgroundColor: 'black', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 8, + backgroundColor: 'white', + }, + headerTitle: { + fontSize: 30, + fontWeight: '800', + letterSpacing: -0.5, + }, + headerButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#E4E6EB', + }, + headerButtonIcon: { + fontSize: 22, + color: '#E41E3F', + }, + badge: { + position: 'absolute', + top: -2, + right: -2, + minWidth: 18, + height: 18, + paddingHorizontal: 5, + borderRadius: 9, + borderWidth: 2, + borderColor: 'white', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#E41E3F', + }, + badgeText: { + color: 'white', + fontSize: 9, + fontWeight: '700', + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 12, + backgroundColor: 'white', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#DADDE1', + }, + searchIcon: { + position: 'absolute', + left: 28, + top: 12, + width: 20, + height: 20, + zIndex: 1, + tintColor: '#65676B', + }, + searchInput: { + flex: 1, + height: 44, + paddingLeft: 42, + paddingRight: 16, + borderRadius: 22, + fontSize: 15, + backgroundColor: '#F0F2F5', + }, + feed: { + flex: 1, + }, + stories: { + marginTop: 8, + flexGrow: 0, + backgroundColor: 'white', + }, + storiesContent: { + paddingHorizontal: 12, + paddingVertical: 12, + gap: 12, + }, + story: { + width: 72, + alignItems: 'center', + }, + storyRing: { + backgroundColor: '#D62976', + padding: 3, + borderRadius: 36, + experimental_backgroundImage: + 'linear-gradient(45deg, #FEDA75, #FA7E1E, #D62976, #962FBF)', + }, + storyRingSeen: { + padding: 3, + borderRadius: 36, + backgroundColor: '#DADDE1', + }, + storyAvatar: { + width: 62, + height: 62, + borderRadius: 31, + borderWidth: 3, + borderColor: 'white', + backgroundColor: '#FFF8E1', + }, + storyAddButton: { + position: 'absolute', + top: 48, + right: 2, + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: 'white', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: PRIMARY_COLOR, + }, + storyAddButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '700', + lineHeight: 16, + }, + storyName: { + marginTop: 6, + fontSize: 12, + color: '#050505', + }, + post: { + marginTop: 8, + paddingVertical: 12, + backgroundColor: 'white', + }, + postHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + postAvatar: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#FFF3E0', + }, + postAuthor: { + flex: 1, + marginLeft: 10, + }, + postAuthorName: { + fontSize: 15, + fontWeight: '600', + color: '#050505', + }, + verified: { + color: PRIMARY_COLOR, + fontSize: 13, + }, + postMeta: { + marginTop: 2, + fontSize: 13, + color: '#65676B', + }, + postMore: { + fontSize: 12, + color: '#65676B', + letterSpacing: 1, + }, + postText: { + marginTop: 10, + marginBottom: 12, + paddingHorizontal: 16, + fontSize: 15, + lineHeight: 21, + color: '#050505', + }, + hashtag: { + color: PRIMARY_COLOR, + fontWeight: '500', + }, + postImage: { + width: '100%', + height: 260, + backgroundColor: '#E4E6EB', + }, + postActions: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 12, + gap: 20, + }, + postAction: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + postActionIcon: { + width: 22, + height: 22, + }, + postActionText: { + fontSize: 14, + fontWeight: '500', + color: '#65676B', + }, + commentIcon: { + width: 20, + height: 18, + borderRadius: 9, + borderBottomLeftRadius: 2, + borderWidth: 2, + borderColor: '#65676B', + }, + shareButton: { + marginLeft: 'auto', + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: '#CED0D4', + }, + shareButtonText: { + fontSize: 13, + fontWeight: '600', + color: '#050505', + }, + postLikedBy: { + marginTop: 10, + paddingHorizontal: 16, + fontSize: 13, + color: '#65676B', + }, + bold: { + fontWeight: '700', + color: '#050505', + }, + tabBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + paddingTop: 8, + paddingBottom: 28, + backgroundColor: 'white', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#DADDE1', + boxShadow: '0 -2px 12px rgba(0, 0, 0, 0.06)', + }, + tab: { + width: 64, + alignItems: 'center', + gap: 4, + }, + tabIcon: { + width: 26, + height: 26, + resizeMode: 'contain', + tintColor: '#65676B', + }, + tabIconActive: { + tintColor: PRIMARY_COLOR, + }, + tabGlyphIcon: { + height: 26, + fontSize: 26, + lineHeight: 26, + color: '#65676B', + }, + tabAvatar: { + width: 26, + height: 26, + borderRadius: 13, + borderWidth: 1, + borderColor: '#CED0D4', + backgroundColor: '#FFF8E1', + }, + tabLabel: { + fontSize: 11, + fontWeight: '500', + color: '#65676B', + }, + tabLabelActive: { + color: PRIMARY_COLOR, + fontWeight: '700', + }, + createButton: { + backgroundColor: PRIMARY_COLOR, + width: 48, + height: 48, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + experimental_backgroundImage: 'linear-gradient(135deg, #3B9BFF, #1462D8)', + boxShadow: '0 4px 10px rgba(24, 119, 242, 0.4)', + }, + createButtonText: { + color: 'white', + fontSize: 28, + fontWeight: '400', + lineHeight: 32, + }, + homeIndicator: { + position: 'absolute', + bottom: 8, + left: '50%', + width: 134, + height: 5, + marginLeft: -67, + borderRadius: 2.5, + backgroundColor: 'black', + }, +}); diff --git a/private/react-native-fantom/src/__tests__/fixtures/RenderingEdgeCases.js b/private/react-native-fantom/src/__tests__/fixtures/RenderingEdgeCases.js new file mode 100644 index 000000000000..338084bc3d0b --- /dev/null +++ b/private/react-native-fantom/src/__tests__/fixtures/RenderingEdgeCases.js @@ -0,0 +1,214 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import * as React from 'react'; +import {View} from 'react-native'; + +component Layer( + left: number, + top: number, + backgroundColor: string, + zIndex: number, +) { + return ( + + ); +} + +export component StackingScene(zIndex: number) { + return ( + + + + + + + + + + + ); +} + +export component OverflowScene(overflow: 'visible' | 'hidden') { + return ( + + + + + + + + + + ); +} + +export component AbsolutePositionScene() { + return ( + + + + + + + + ); +} + +export component TransformScene() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/private/react-native-fantom/src/__tests__/fixtures/TravelScreen.js b/private/react-native-fantom/src/__tests__/fixtures/TravelScreen.js new file mode 100644 index 000000000000..22a2616bda3c --- /dev/null +++ b/private/react-native-fantom/src/__tests__/fixtures/TravelScreen.js @@ -0,0 +1,173 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ScrollViewInstance} from 'react-native'; + +import * as React from 'react'; +import { + Image, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +export default component TravelScreen( + query: string = '', + scrollRef?: React.RefObject, +) { + return ( + + + WEEKEND CLUB + Find your next escape. + Small adventures. Great memories. + + + + + + For you + + Outdoors + City breaks + + + A little closer to nature + See all + + + + + TRAIL OF THE WEEK + + The quiet side of the mountains + + + Follow the pines to an open valley, with room to slow down and + take it all in. + + + 6.4 km loop + Easy pace + 4.9 rating + + + Explore this trail + + + + + Make a day of it + + Pack a picnic, invite a friend, and save a little time for the + scenic route home. + + + + Your saved places + + Keep your favorite escapes together. Your next weekend starts here. + + + + + Discover + Saved + Profile + + + ); +} + +const styles = StyleSheet.create({ + screen: {flex: 1, backgroundColor: '#f6f7f2'}, + header: {paddingTop: 40, paddingHorizontal: 24, paddingBottom: 20, gap: 10}, + eyebrow: {fontSize: 11, fontWeight: '700', color: '#426c58'}, + title: {fontSize: 28, fontWeight: '700', color: '#203c30'}, + subtitle: {fontSize: 14, color: '#718078'}, + search: { + height: 48, + borderRadius: 12, + backgroundColor: 'white', + borderWidth: 1, + borderColor: '#dce3d9', + paddingHorizontal: 16, + fontSize: 14, + color: '#203c30', + marginTop: 8, + }, + feed: {flex: 1}, + content: {paddingHorizontal: 24, paddingBottom: 24, gap: 18}, + categories: {flexDirection: 'row', alignItems: 'center', gap: 24}, + selected: { + paddingHorizontal: 18, + paddingVertical: 10, + backgroundColor: '#254f3d', + borderRadius: 20, + }, + selectedText: {fontSize: 13, fontWeight: '600', color: 'white'}, + category: {fontSize: 13, color: '#758077'}, + section: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sectionTitle: {fontSize: 16, fontWeight: '700', color: '#203c30'}, + link: {fontSize: 12, fontWeight: '600', color: '#426c58'}, + card: { + borderRadius: 18, + backgroundColor: 'white', + overflow: 'hidden', + borderWidth: 1, + borderColor: '#e1e6dc', + }, + photo: {width: '100%', height: 180}, + cardBody: {padding: 20, gap: 12}, + cardTitle: {fontSize: 20, fontWeight: '700', color: '#203c30'}, + description: {fontSize: 14, lineHeight: 21, color: '#718078'}, + details: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 4, + }, + detail: {fontSize: 11, color: '#426c58'}, + button: { + backgroundColor: '#254f3d', + borderRadius: 10, + paddingVertical: 14, + alignItems: 'center', + }, + buttonText: {fontSize: 14, fontWeight: '600', color: 'white'}, + tip: {padding: 20, backgroundColor: '#eaf0e1', borderRadius: 14, gap: 10}, + navigation: { + height: 72, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + backgroundColor: 'white', + borderTopWidth: 1, + borderTopColor: '#e1e6dc', + }, + activeNav: {fontSize: 13, fontWeight: '700', color: '#254f3d'}, +}); diff --git a/private/react-native-fantom/src/__tests__/fixtures/feed-hotdog.png b/private/react-native-fantom/src/__tests__/fixtures/feed-hotdog.png new file mode 100644 index 000000000000..97257260bd43 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/fixtures/feed-hotdog.png differ diff --git a/private/react-native-fantom/src/__tests__/fixtures/feed-trees.png b/private/react-native-fantom/src/__tests__/fixtures/feed-trees.png new file mode 100644 index 000000000000..fb44ed6efbf2 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/fixtures/feed-trees.png differ diff --git a/private/react-native-fantom/src/__tests__/fixtures/travelImage.png b/private/react-native-fantom/src/__tests__/fixtures/travelImage.png new file mode 100644 index 000000000000..dc689d7de6c6 Binary files /dev/null and b/private/react-native-fantom/src/__tests__/fixtures/travelImage.png differ diff --git a/private/react-native-fantom/src/index.js b/private/react-native-fantom/src/index.js index 98937065dc6c..91ddef90f6ec 100644 --- a/private/react-native-fantom/src/index.js +++ b/private/react-native-fantom/src/index.js @@ -23,6 +23,7 @@ import NativeFantom, { import * as Benchmark from './Benchmark'; import {getConstants} from './Constants'; import getFantomRenderedOutput from './getFantomRenderedOutput'; +import Screenshot from './Screenshot'; import {Dimensions, LogBox} from 'react-native'; import { getInternalInstanceHandleFromPublicInstance, @@ -120,6 +121,7 @@ class Root { #devicePixelRatio: number; #fontScale: number; #document: ?Document; + #destroyed: boolean = false; constructor(config?: RootConfig) { this.#viewportWidth = config?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH; @@ -188,6 +190,17 @@ class Root { popDimensions(this.#surfaceId); NativeFantom.flushMessageQueue(); this.#document = null; + this.#destroyed = true; + } + + /** + * Captures the mounted pixels without flushing pending React work. + */ + takeScreenshot(): Screenshot { + if (this.#destroyed) { + throw new Error('Cannot capture a screenshot of a destroyed root.'); + } + return new Screenshot(NativeFantom.takeScreenshot(this.#surfaceId)); } getRenderedOutput(config: RenderOutputConfig = {}): FantomRenderedOutput { @@ -204,6 +217,7 @@ class Root { export type {Root}; export {NativeEventCategory}; +export type {default as Screenshot} from './Screenshot'; const DEFAULT_TASK_PRIORITY = unstable_ImmediatePriority; diff --git a/private/react-native-fantom/src/setUpAssetSourceResolver.js b/private/react-native-fantom/src/setUpAssetSourceResolver.js new file mode 100644 index 000000000000..5b1144c0c327 --- /dev/null +++ b/private/react-native-fantom/src/setUpAssetSourceResolver.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import NativeFantom from '../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import {Image} from 'react-native'; + +export default function setUpAssetSourceResolver(): void { + const resolveAssetSource = Image.resolveAssetSource; + resolveAssetSource.addCustomSourceTransformer(resolver => { + const asset: unknown = resolver.asset; + if ( + asset == null || + typeof asset !== 'object' || + !('fantomFiles' in asset) || + !Array.isArray(asset.fantomFiles) + ) { + return null; + } + const files = asset.fantomFiles; + const scale = resolveAssetSource.pickScale(resolver.asset.scales); + const file = files[resolver.asset.scales.indexOf(scale)]; + if (typeof file !== 'string') { + throw new Error( + `Fantom could not find a bundled file for ${resolver.asset.name}.${resolver.asset.type} at scale ${scale}.`, + ); + } + const source = resolver.defaultAsset(); + NativeFantom.registerAsset(source.uri, file); + return source; + }); +} diff --git a/private/react-native-fantom/src/setUpDefaultFonts.js b/private/react-native-fantom/src/setUpDefaultFonts.js new file mode 100644 index 000000000000..5994539046d2 --- /dev/null +++ b/private/react-native-fantom/src/setUpDefaultFonts.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import NativeFantom from '../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import nullthrows from 'nullthrows'; +import {Image} from 'react-native'; + +export default function setUpDefaultFonts(): void { + for (const asset of [ + require('dejavu-fonts-ttf/ttf/DejaVuSans.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-Bold.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-Oblique.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-BoldOblique.ttf'), + require('@expo-google-fonts/noto-color-emoji/400Regular/NotoColorEmoji_400Regular.ttf'), + ]) { + const assetId: unknown = asset; + if (typeof assetId !== 'number') { + throw new Error('Expected a bundled Fantom font asset ID.'); + } + NativeFantom.registerFont( + nullthrows(Image.resolveAssetSource(assetId)).uri, + ); + } +} diff --git a/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js b/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js index cc9aeaadac41..32d8c3e815a9 100644 --- a/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js +++ b/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js @@ -9,6 +9,8 @@ */ import setUpDefaultReactNativeEnvironment from '../../../packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment'; +import setUpFantomEnvironment from './setUpFantomEnvironment'; // Do NOT enable developer tools for Fantom. setUpDefaultReactNativeEnvironment(false); +setUpFantomEnvironment(); diff --git a/private/react-native-fantom/src/setUpFantomEnvironment.js b/private/react-native-fantom/src/setUpFantomEnvironment.js new file mode 100644 index 000000000000..e2193c2f0613 --- /dev/null +++ b/private/react-native-fantom/src/setUpFantomEnvironment.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import setUpAssetSourceResolver from './setUpAssetSourceResolver'; +import setUpDefaultFonts from './setUpDefaultFonts'; + +let initialized = false; + +export default function setUpFantomEnvironment(): void { + if (initialized) { + return; + } + + setUpAssetSourceResolver(); + setUpDefaultFonts(); + initialized = true; +} diff --git a/private/react-native-fantom/tester/CMakeLists.txt b/private/react-native-fantom/tester/CMakeLists.txt index af4c741f5ab6..313616c74ee8 100644 --- a/private/react-native-fantom/tester/CMakeLists.txt +++ b/private/react-native-fantom/tester/CMakeLists.txt @@ -3,7 +3,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -cmake_minimum_required(VERSION 3.13) +cmake_minimum_required(VERSION 3.22) set(CMAKE_VERBOSE_MAKEFILE on) if(UNIX AND NOT APPLE) set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -24,12 +24,12 @@ find_library(LIB_HERMES hermesvm add_library(hermes-engine::hermesvm INTERFACE IMPORTED) set_target_properties(hermes-engine::hermesvm PROPERTIES INTERFACE_LINK_LIBRARIES ${LIB_HERMES}) -find_package(OpenSSL REQUIRED) # Boost in NDK is not compatible with desktop build add_third_party_subdir(boost) # Third-party downloaded targets +add_third_party_subdir(boringssl) add_react_third_party_ndk_subdir(glog) add_react_third_party_ndk_subdir(double-conversion) add_react_third_party_ndk_subdir(fast_float) @@ -37,6 +37,7 @@ add_react_third_party_ndk_subdir(fmt) add_fantom_third_party_subdir(folly) add_fantom_third_party_subdir(gflags) add_fantom_third_party_subdir(nlohmann_json) +add_third_party_subdir(skia) # Marks everything added below as React Native's own build, to enable private # includes in Fantom. @@ -145,6 +146,7 @@ file(GLOB SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/stubs/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/oss/*.cpp ) +list(APPEND SOURCES ${REACT_COMMON_DIR}/react/renderer/components/textinput/BaseTextInputProps.cpp) add_executable(fantom_tester ${SOURCES}) target_include_directories(fantom_tester PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) @@ -158,6 +160,8 @@ target_link_libraries(fantom_tester folly_runtime gflags glog + fantom_skia + OpenSSL::Crypto # hermes hermes-engine::hermesvm diff --git a/private/react-native-fantom/tester/src/NativeFantom.cpp b/private/react-native-fantom/tester/src/NativeFantom.cpp index 366156b89de7..0567b1746af1 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.cpp +++ b/private/react-native-fantom/tester/src/NativeFantom.cpp @@ -14,14 +14,17 @@ #include #include #include +#include #include #include #include "TesterAppDelegate.h" #include +#include "render/ImageRenderer.h" #include "render/RenderFormatOptions.h" #include "render/RenderOutput.h" +#include "render/TextLayout.h" namespace facebook::react { @@ -114,6 +117,41 @@ std::string NativeFantom::getRenderedOutput( viewTree, formatOptions); } +jsi::Object NativeFantom::takeScreenshot( + jsi::Runtime& runtime, + SurfaceId surfaceId) { + try { + auto screenshot = ImageRenderer{}.capture( + appDelegate_.mountingManager_->getViewTree(surfaceId), assetPaths_); + auto object = jsi::Object::createFromHostObject(runtime, screenshot); + object.setExternalMemoryPressure(runtime, screenshot->getMemorySize()); + return object; + } catch (const std::exception& error) { + throw jsi::JSError( + runtime, + std::string("Cannot render Fantom screenshot: ") + error.what()); + } +} + +void NativeFantom::registerAsset( + jsi::Runtime& /*runtime*/, + const std::string& uri, + const std::string& filePath) { + assetPaths_.insert_or_assign(uri, filePath); +} + +void NativeFantom::registerFont( + jsi::Runtime& runtime, + const std::string& filePath) { + try { + const auto asset = assetPaths_.find(filePath); + facebook::react::registerFont( + asset == assetPaths_.end() ? filePath : asset->second); + } catch (const std::exception& error) { + throw jsi::JSError(runtime, error.what()); + } +} + void NativeFantom::reportTestSuiteResultsJSON( jsi::Runtime& /*runtime*/, const std::string& testSuiteResultsJSON) { diff --git a/private/react-native-fantom/tester/src/NativeFantom.h b/private/react-native-fantom/tester/src/NativeFantom.h index 1466020200b9..b26aa851c17e 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.h +++ b/private/react-native-fantom/tester/src/NativeFantom.h @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace facebook::react { @@ -101,6 +102,10 @@ class NativeFantom : public NativeFantomCxxSpec { SurfaceId surfaceId, NativeFantomGetRenderedOutputRenderFormatOptions options); + jsi::Object takeScreenshot(jsi::Runtime &runtime, SurfaceId surfaceId); + void registerAsset(jsi::Runtime &runtime, const std::string &uri, const std::string &filePath); + void registerFont(jsi::Runtime &runtime, const std::string &filePath); + void reportTestSuiteResultsJSON(jsi::Runtime &runtime, const std::string &testSuiteResultsJSON); void enqueueNativeEvent( @@ -149,6 +154,7 @@ class NativeFantom : public NativeFantomCxxSpec { private: TesterAppDelegate &appDelegate_; + std::unordered_map assetPaths_; SurfaceId nextSurfaceId_ = 1; }; diff --git a/private/react-native-fantom/tester/src/TesterMountingManager.cpp b/private/react-native-fantom/tester/src/TesterMountingManager.cpp index 4526eace7d21..ae79e5f1f4a4 100644 --- a/private/react-native-fantom/tester/src/TesterMountingManager.cpp +++ b/private/react-native-fantom/tester/src/TesterMountingManager.cpp @@ -21,6 +21,8 @@ TesterMountingManager::TesterMountingManager( std::function&& onAfterMount) : onAfterMount_(onAfterMount), renderer_(std::make_unique()) { imageLoader_ = std::make_shared(); + textLayoutManager_ = std::make_shared( + std::make_shared()); } void TesterMountingManager::executeMount( @@ -116,7 +118,7 @@ folly::dynamic TesterMountingManager::getViewFabricUpdateProps( } ComponentRegistryFactory TesterMountingManager::getComponentRegistryFactory() { - return getDefaultComponentRegistryFactory(); + return getDefaultComponentRegistryFactory(textLayoutManager_); } StubViewTree TesterMountingManager::getViewTree(SurfaceId surfaceId) { diff --git a/private/react-native-fantom/tester/src/TesterMountingManager.h b/private/react-native-fantom/tester/src/TesterMountingManager.h index cdfdf96d584d..b8a8e506c178 100644 --- a/private/react-native-fantom/tester/src/TesterMountingManager.h +++ b/private/react-native-fantom/tester/src/TesterMountingManager.h @@ -15,6 +15,7 @@ #include #include "render/RenderOutput.h" +#include "render/TextLayout.h" namespace facebook::react { class IImageLoader; @@ -47,6 +48,7 @@ class TesterMountingManager : public IMountingManager { } std::shared_ptr imageLoader_; + std::shared_ptr textLayoutManager_; std::shared_ptr getImageLoader() noexcept override { diff --git a/private/react-native-fantom/tester/src/render/ImageRenderer.cpp b/private/react-native-fantom/tester/src/render/ImageRenderer.cpp new file mode 100644 index 000000000000..6848f06a43e3 --- /dev/null +++ b/private/react-native-fantom/tester/src/render/ImageRenderer.cpp @@ -0,0 +1,456 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "ImageRenderer.h" +#include "../stubs/FantomTextInput.h" +#include "TextLayout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { +namespace { + +SkPaint color(SharedColor value) { + SkPaint paint; + paint.setAntiAlias(true); + paint.setColor(SkColorSetARGB( + alphaFromColor(value), + redFromColor(value), + greenFromColor(value), + blueFromColor(value))); + return paint; +} + +SkRRect roundedRect(Size size, const BorderRadii& radii) { + std::array corners{}; + int index = 0; + for (const auto& radius : + {radii.topLeft, radii.topRight, radii.bottomRight, radii.bottomLeft}) { + corners[index++] = SkVector::Make( + std::min(radius.horizontal, size.width / 2), + std::min(radius.vertical, size.height / 2)); + } + SkRRect rect; + rect.setRectRadii(SkRect::MakeWH(size.width, size.height), corners.data()); + return rect; +} + +sk_sp loadImage(const std::string& uri) { + constexpr std::string_view prefix = "data:image/png;base64,"; + sk_sp bytes; + if (uri.starts_with(prefix)) { + const auto encoded = std::string_view(uri).substr(prefix.size()); + size_t decodedSize = 0; + if (SkBase64::Decode( + encoded.data(), encoded.size(), nullptr, &decodedSize) != + SkBase64::kNoError) { + throw std::runtime_error("Cannot decode Fantom PNG data source"); + } + bytes = SkData::MakeUninitialized(decodedSize); + if (SkBase64::Decode( + encoded.data(), + encoded.size(), + bytes->writable_data(), + &decodedSize) != SkBase64::kNoError) { + throw std::runtime_error("Cannot decode Fantom PNG data source"); + } + } else { + if (uri.starts_with("http:") || uri.starts_with("https:") || + uri.starts_with("data:")) { + throw std::runtime_error( + "Fantom screenshots support local PNG files and data:image/png;base64 sources: " + + uri.substr(0, 100)); + } + const auto path = uri.starts_with("file://") ? uri.substr(7) : uri; + bytes = SkData::MakeFromFileName(path.c_str()); + } + const auto codec = bytes ? SkCodec::MakeFromData(std::move(bytes)) : nullptr; + if (!codec || codec->getEncodedFormat() != SkEncodedImageFormat::kPNG) { + throw std::runtime_error( + "Cannot decode Fantom PNG image: " + uri.substr(0, 100)); + } + SkBitmap bitmap; + const auto info = codec->getInfo() + .makeColorType(kN32_SkColorType) + .makeAlphaType(kPremul_SkAlphaType); + if (!bitmap.tryAllocPixels(info)) { + throw std::bad_alloc(); + } + if (codec->getPixels(bitmap.pixmap()) != SkCodec::kSuccess) { + throw std::runtime_error("Cannot decode Fantom PNG pixels"); + } + bitmap.setImmutable(); + auto image = SkImage::MakeFromBitmap(bitmap); + if (!image) { + throw std::runtime_error("Cannot create Fantom PNG image"); + } + return image; +} + +void paintBorders(SkCanvas& canvas, Size size, const BorderMetrics& border) { + const auto& widths = border.borderWidths; + if (widths.left + widths.right + widths.top + widths.bottom <= 0) { + return; + } + canvas.save(); + SkPath ring; + ring.addRRect(roundedRect(size, border.borderRadii)); + const Size innerSize{ + .width = std::max(0, size.width - widths.left - widths.right), + .height = std::max(0, size.height - widths.top - widths.bottom)}; + if (innerSize.width > 0 && innerSize.height > 0) { + auto innerRadii = border.borderRadii; + const auto inset = [](CornerRadii& radius, + Float horizontal, + Float vertical) { + radius.horizontal = std::max(0, radius.horizontal - horizontal); + radius.vertical = std::max(0, radius.vertical - vertical); + }; + inset(innerRadii.topLeft, widths.left, widths.top); + inset(innerRadii.topRight, widths.right, widths.top); + inset(innerRadii.bottomLeft, widths.left, widths.bottom); + inset(innerRadii.bottomRight, widths.right, widths.bottom); + auto inner = roundedRect(innerSize, innerRadii); + inner.offset(widths.left, widths.top); + ring.addRRect(inner); + } + ring.setFillType(SkPathFillType::kEvenOdd); + canvas.clipPath(ring, true); + if (border.borderColors.top && + border.borderColors.top == border.borderColors.right && + border.borderColors.top == border.borderColors.bottom && + border.borderColors.top == border.borderColors.left) { + canvas.drawPaint(color(border.borderColors.top)); + canvas.restore(); + return; + } + const auto edge = [&](std::initializer_list points, + SharedColor value) { + if (!value) { + return; + } + SkPath path; + for (auto point = points.begin(); point != points.end(); ++point) { + if (point == points.begin()) { + path.moveTo(point->x, point->y); + } else { + path.lineTo(point->x, point->y); + } + } + path.close(); + canvas.drawPath(path, color(value)); + }; + const auto w = size.width; + const auto h = size.height; + edge( + {{.x = 0, .y = 0}, + {.x = w, .y = 0}, + {.x = w - widths.right, .y = widths.top}, + {.x = widths.left, .y = widths.top}}, + border.borderColors.top); + edge( + {{.x = w, .y = 0}, + {.x = w, .y = h}, + {.x = w - widths.right, .y = h - widths.bottom}, + {.x = w - widths.right, .y = widths.top}}, + border.borderColors.right); + edge( + {{.x = w, .y = h}, + {.x = 0, .y = h}, + {.x = widths.left, .y = h - widths.bottom}, + {.x = w - widths.right, .y = h - widths.bottom}}, + border.borderColors.bottom); + edge( + {{.x = 0, .y = h}, + {.x = 0, .y = 0}, + {.x = widths.left, .y = widths.top}, + {.x = widths.left, .y = h - widths.bottom}}, + border.borderColors.left); + canvas.restore(); +} +void paintImage( + SkCanvas& canvas, + const StubView& view, + const ImageProps& props, + const AssetPathMap& assetPaths) { + auto source = + props.sources.empty() ? props.defaultSource : props.sources.front(); + if (view.state) { + source = static_cast&>(*view.state) + .getData() + .getImageSource(); + } + const auto size = view.layoutMetrics.frame.size; + if (source.uri.empty() || size.width <= 0 || size.height <= 0) { + return; + } + const auto asset = assetPaths.find(source.uri); + auto image = + loadImage(asset == assetPaths.end() ? source.uri : asset->second); + const double width = image->width(); + const double height = image->height(); + double sx = size.width / width; + double sy = size.height / height; + switch (props.resizeMode) { + case ImageResizeMode::Cover: + sx = sy = std::max(sx, sy); + break; + case ImageResizeMode::Contain: + sx = sy = std::min(sx, sy); + break; + case ImageResizeMode::Center: + sx = sy = std::min(1.0, std::min(sx, sy)); + break; + case ImageResizeMode::Repeat: + case ImageResizeMode::None: + sx = sy = 1.0 / std::max(source.scale, 1); + break; + case ImageResizeMode::Stretch: + break; + } + canvas.save(); + canvas.clipRRect( + roundedRect( + size, props.resolveBorderMetrics(view.layoutMetrics).borderRadii), + true); + SkPaint paint; + paint.setAntiAlias(true); + if (props.tintColor && *props.tintColor) { + paint.setColorFilter( + SkColorFilters::Blend( + color(*props.tintColor).getColor(), SkBlendMode::kSrcIn)); + } + const SkSamplingOptions sampling(SkFilterMode::kLinear); + if (props.resizeMode == ImageResizeMode::Repeat) { + SkMatrix scale; + scale.setScale(static_cast(sx), static_cast(sy)); + paint.setShader(image->makeShader( + SkTileMode::kRepeat, SkTileMode::kRepeat, sampling, scale)); + canvas.drawRect(SkRect::MakeWH(size.width, size.height), paint); + } else { + const auto destination = SkRect::MakeXYWH( + static_cast((size.width - width * sx) / 2), + static_cast((size.height - height * sy) / 2), + static_cast(width * sx), + static_cast(height * sy)); + canvas.drawImageRect(image, destination, sampling, &paint); + } + canvas.restore(); +} + +void paintView( + SkCanvas& canvas, + const StubView& view, + const AssetPathMap& assetPaths) { + const auto& metrics = view.layoutMetrics; + if (metrics.displayType == DisplayType::None) { + return; + } + const auto* props = dynamic_cast(view.props.get()); + canvas.save(); + canvas.translate(metrics.frame.origin.x, metrics.frame.origin.y); + if (props) { + const auto& matrix = props->resolveTransform(metrics).matrix; + const auto determinant = static_cast(matrix[0]) * matrix[5] - + static_cast(matrix[1]) * matrix[4]; + if (determinant == 0 || !std::isfinite(determinant)) { + canvas.restore(); + return; + } + SkMatrix transform; + transform.setAll( + matrix[0], + matrix[4], + matrix[12], + matrix[1], + matrix[5], + matrix[13], + 0, + 0, + 1); + if (!transform.isIdentity()) { + canvas.translate( + metrics.frame.size.width / 2, metrics.frame.size.height / 2); + canvas.concat(transform); + canvas.translate( + -metrics.frame.size.width / 2, -metrics.frame.size.height / 2); + } + if (props->opacity < 1) { + canvas.saveLayerAlpha( + nullptr, static_cast(std::round(255 * props->opacity))); + } + const auto border = props->resolveBorderMetrics(metrics); + if (props->backgroundColor) { + canvas.drawRRect( + roundedRect(metrics.frame.size, border.borderRadii), + color(props->backgroundColor)); + } + if (props->getClipsContentToBounds()) { + canvas.clipRRect( + roundedRect(metrics.frame.size, border.borderRadii), true); + } + } + + const std::string_view name(view.componentName ? view.componentName : ""); + if (name == "Paragraph" && view.state) { + const auto& state = + static_cast&>(*view.state) + .getData(); + const auto frame = metrics.getContentFrame(); + canvas.save(); + canvas.translate(frame.origin.x, frame.origin.y); + canvas.clipRect(SkRect::MakeWH(frame.size.width, frame.size.height)); + paintText( + &canvas, + layoutText( + &canvas, + state.attributedString, + state.paragraphAttributes, + frame.size.width), + frame.size.width); + canvas.restore(); + } else if ( + const auto* input = + dynamic_cast(view.props.get())) { + auto attributes = + input->getEffectiveTextAttributes(metrics.fontSizeMultiplier); + const bool placeholder = input->text.empty(); + if (placeholder) { + attributes.foregroundColor = input->placeholderTextColor + ? input->placeholderTextColor + : colorFromRGBA(128, 128, 128, 255); + } + auto text = placeholder ? input->placeholder : input->text; + if (!placeholder && input->secureTextEntry) { + std::string masked; + for (const unsigned char character : text) { + if ((character & 0xc0) != 0x80) { + masked += "•"; + } + } + text = masked; + } + AttributedString string; + string.appendFragment( + {.string = text, .textAttributes = attributes, .parentShadowView = {}}); + const auto frame = metrics.getContentFrame(); + auto paragraph = input->paragraphAttributes; + if (!input->multiline) { + paragraph.maximumNumberOfLines = 1; + } + auto layout = layoutText(&canvas, string, paragraph, frame.size.width); + canvas.save(); + canvas.translate(frame.origin.x, frame.origin.y); + canvas.clipRect(SkRect::MakeWH(frame.size.width, frame.size.height)); + if (!input->multiline) { + canvas.translate( + 0, + std::max( + 0, (frame.size.height - layout.measurement.size.height) / 2)); + } + paintText(&canvas, layout, frame.size.width); + canvas.restore(); + } else if ( + const auto* image = dynamic_cast(view.props.get())) { + paintImage(canvas, view, *image, assetPaths); + } + + canvas.save(); + if (name == "ScrollView") { + canvas.clipRect( + SkRect::MakeWH(metrics.frame.size.width, metrics.frame.size.height)); + if (view.state) { + const auto& state = + static_cast&>(*view.state) + .getData(); + canvas.translate(-state.contentOffset.x, -state.contentOffset.y); + } + } + for (const auto& child : view.children) { + paintView(canvas, *child, assetPaths); + } + canvas.restore(); + + if (props) { + paintBorders( + canvas, metrics.frame.size, props->resolveBorderMetrics(metrics)); + if (props->opacity < 1) { + canvas.restore(); + } + } + canvas.restore(); +} + +SkBitmap renderSurface( + const StubViewTree& tree, + const AssetPathMap& assetPaths) { + const auto& root = tree.getRootStubView(); + const auto scale = root.layoutMetrics.pointScaleFactor; + const auto width = std::ceil( + static_cast(root.layoutMetrics.frame.size.width) * scale); + const auto height = std::ceil( + static_cast(root.layoutMetrics.frame.size.height) * scale); + if (!std::isfinite(width) || !std::isfinite(height) || width <= 0 || + height <= 0 || width * height > 64.0 * 1024 * 1024) { + throw std::runtime_error( + "Invalid Fantom screenshot dimensions (maximum 64 million pixels)"); + } + SkBitmap surface; + if (!surface.tryAllocPixels( + SkImageInfo::Make( + static_cast(width), + static_cast(height), + kBGRA_8888_SkColorType, + kPremul_SkAlphaType))) { + throw std::bad_alloc(); + } + surface.eraseColor(SK_ColorTRANSPARENT); + SkCanvas canvas(surface); + canvas.scale(scale, scale); + for (const auto& child : root.children) { + paintView(canvas, *child, assetPaths); + } + return surface; +} +} // namespace + +std::shared_ptr ImageRenderer::capture( + const StubViewTree& tree, + const AssetPathMap& assetPaths) const { + return std::make_shared(renderSurface(tree, assetPaths)); +} + +std::vector ImageRenderer::render( + const StubViewTree& tree) const { + return capture(tree)->getPNG(); +} + +void ImageRenderer::renderToFile( + const StubViewTree& tree, + const std::string& filePath) const { + capture(tree)->saveToFile(filePath); +} + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/ImageRenderer.h b/private/react-native-fantom/tester/src/render/ImageRenderer.h new file mode 100644 index 000000000000..68f638748e74 --- /dev/null +++ b/private/react-native-fantom/tester/src/render/ImageRenderer.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include +#include "Screenshot.h" + +namespace facebook::react { + +using AssetPathMap = std::unordered_map; + +// Renders a mounted revision without consulting React or the live shadow tree. +// The encoded bytes can also be retained by a mount transaction recorder. +class ImageRenderer { + public: + std::shared_ptr capture(const StubViewTree &tree, const AssetPathMap &assetPaths = {}) const; + std::vector render(const StubViewTree &tree) const; + void renderToFile(const StubViewTree &tree, const std::string &filePath) const; +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/Screenshot.cpp b/private/react-native-fantom/tester/src/render/Screenshot.cpp new file mode 100644 index 000000000000..970f0f4d14e5 --- /dev/null +++ b/private/react-native-fantom/tester/src/render/Screenshot.cpp @@ -0,0 +1,196 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "Screenshot.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +Screenshot::Screenshot(Surface surface) : surface_(std::move(surface)) { + surface_.setImmutable(); +} + +size_t Screenshot::getMemorySize() const { + return static_cast(surface_.rowBytes()) * surface_.height() + + (png_ ? png_->size() : 0); +} + +const std::string& Screenshot::getHash() { + if (hash_) { + return *hash_; + } + auto context = std::unique_ptr( + EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!context || + EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1) { + throw std::runtime_error("Cannot initialize screenshot hash"); + } + const auto width = surface_.width(); + const auto height = surface_.height(); + std::array dimensions{}; + for (size_t index = 0; index < 4; ++index) { + dimensions[index] = static_cast(width >> (24 - index * 8)); + dimensions[index + 4] = + static_cast(height >> (24 - index * 8)); + } + if (EVP_DigestUpdate(context.get(), dimensions.data(), dimensions.size()) != + 1) { + throw std::runtime_error("Cannot hash screenshot dimensions"); + } + const auto* data = static_cast(surface_.getPixels()); + if (data == nullptr) { + throw std::runtime_error("Cannot read screenshot pixels"); + } + const auto stride = static_cast(surface_.rowBytes()); + const auto pixels = std::span(data, stride * height); + std::vector row(static_cast(width) * 4); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const auto offset = static_cast(x) * 4; + const auto pixel = pixels.subspan(y * stride + offset, 4); + row[offset] = pixel[2]; + row[offset + 1] = pixel[1]; + row[offset + 2] = pixel[0]; + row[offset + 3] = pixel[3]; + } + if (EVP_DigestUpdate(context.get(), row.data(), row.size()) != 1) { + throw std::runtime_error("Cannot hash screenshot pixels"); + } + } + std::array digest{}; + unsigned int length = 0; + if (EVP_DigestFinal_ex(context.get(), digest.data(), &length) != 1) { + throw std::runtime_error("Cannot finish screenshot hash"); + } + constexpr std::string_view digits = "0123456789abcdef"; + std::string hash; + for (const auto byte : std::span(digest.data(), length)) { + hash += digits[byte >> 4]; + hash += digits[byte & 15]; + } + hash_ = std::move(hash); + return *hash_; +} + +bool Screenshot::isEqual(const Screenshot& other) const { + const auto width = surface_.width(); + const auto height = surface_.height(); + if (width != other.surface_.width() || height != other.surface_.height()) { + return false; + } + const auto* data = static_cast(surface_.getPixels()); + const auto* otherData = + static_cast(other.surface_.getPixels()); + if (data == nullptr || otherData == nullptr) { + throw std::runtime_error("Cannot read screenshot pixels"); + } + const auto stride = static_cast(surface_.rowBytes()); + const auto otherStride = static_cast(other.surface_.rowBytes()); + const auto pixels = std::span(data, stride * height); + const auto otherPixels = std::span(otherData, otherStride * height); + const auto rowSize = static_cast(width) * 4; + for (int row = 0; row < height; ++row) { + if (!std::ranges::equal( + pixels.subspan(row * stride, rowSize), + otherPixels.subspan(row * otherStride, rowSize))) { + return false; + } + } + return true; +} + +const std::vector& Screenshot::getPNG() { + if (!png_) { + SkDynamicMemoryWStream stream; + if (!SkPngEncoder::Encode(&stream, surface_.pixmap(), {})) { + throw std::runtime_error("Cannot encode Fantom screenshot PNG"); + } + const auto bytes = stream.detachAsData(); + const auto* data = static_cast(bytes->data()); + const auto pixels = std::span(data, bytes->size()); + png_ = std::vector(pixels.begin(), pixels.end()); + } + return *png_; +} + +void Screenshot::saveToFile(const std::string& filePath) { + if (filePath.empty() || filePath.find('\0') != std::string::npos) { + throw std::runtime_error( + "Screenshot path must be nonempty and contain no NUL characters"); + } + const auto& bytes = getPNG(); + const std::filesystem::path path(filePath); + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path()); + } + std::ofstream file(filePath, std::ios::binary | std::ios::trunc); + // NOLINTBEGIN(cppcoreguidelines-pro-type-reinterpret-cast) + file.write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast) + file.close(); + if (!file) { + throw std::runtime_error("Cannot write Fantom screenshot to " + filePath); + } +} + +jsi::Value Screenshot::get(jsi::Runtime& runtime, const jsi::PropNameID& name) { + const auto property = name.utf8(runtime); + if (property != "getHash" && property != "saveToFile" && + property != "isEqual") { + return jsi::Value::undefined(); + } + return jsi::Function::createFromHostFunction( + runtime, + name, + property == "getHash" ? 0 : 1, + [screenshot = shared_from_this(), property]( + jsi::Runtime& runtime, + const jsi::Value& receiver, + const jsi::Value* arguments, + size_t count) -> jsi::Value { + const auto args = std::span(arguments, count); + try { + if (property == "getHash") { + return jsi::String::createFromUtf8(runtime, screenshot->getHash()); + } + if (property == "isEqual") { + if (count != 1 || !args[0].isObject() || + !args[0].asObject(runtime).isHostObject(runtime)) { + return false; + } + return screenshot->isEqual( + *args[0].asObject(runtime).getHostObject(runtime)); + } + if (count != 1 || !args[0].isString()) { + throw jsi::JSError(runtime, "Expected a screenshot file path"); + } + screenshot->saveToFile(args[0].asString(runtime).utf8(runtime)); + if (receiver.isObject()) { + receiver.asObject(runtime).setExternalMemoryPressure( + runtime, screenshot->getMemorySize()); + } + return jsi::Value::undefined(); + } catch (const std::exception& error) { + throw jsi::JSError(runtime, error.what()); + } + }); +} + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/Screenshot.h b/private/react-native-fantom/tester/src/render/Screenshot.h new file mode 100644 index 000000000000..74212131fd7e --- /dev/null +++ b/private/react-native-fantom/tester/src/render/Screenshot.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace facebook::react { + +class Screenshot : public jsi::HostObject, public std::enable_shared_from_this { + public: + using Surface = SkBitmap; + + explicit Screenshot(Surface surface); + size_t getMemorySize() const; + const std::string &getHash(); + bool isEqual(const Screenshot &other) const; + const std::vector &getPNG(); + void saveToFile(const std::string &filePath); + jsi::Value get(jsi::Runtime &runtime, const jsi::PropNameID &name) override; + + private: + Surface surface_; + std::optional hash_; + std::optional> png_; +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/TextLayout.cpp b/private/react-native-fantom/tester/src/render/TextLayout.cpp new file mode 100644 index 000000000000..a065fd528ea6 --- /dev/null +++ b/private/react-native-fantom/tester/src/render/TextLayout.cpp @@ -0,0 +1,586 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "TextLayout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef FANTOM_SKIA_FACTORY_PROVIDED +/* static */ sk_sp SkFontMgr::Factory() { + return SkFontMgr_New_Custom_Empty(); +} +#endif + +namespace facebook::react { +namespace { + +constexpr auto defaultFontFamily = "DejaVu Sans"; +constexpr auto emojiFontFamily = "Noto Color Emoji"; + +struct RegisteredFont { + std::string family; + sk_sp face; + std::shared_ptr shapingFont; +}; +struct FontRegistry { + std::vector fonts; + std::mutex mutex; +}; + +FontRegistry& fontRegistry() { + static FontRegistry registry; + return registry; +} + +std::shared_ptr makeShapingFont(SkTypeface* typeface) { + const auto face = std::unique_ptr( + hb_face_create_for_tables( + [](hb_face_t*, hb_tag_t tag, void* data) { + auto table = static_cast(data)->copyTableData(tag); + if (!table) { + return hb_blob_get_empty(); + } + auto* bytes = table.release(); + return hb_blob_create( + static_cast(bytes->data()), + static_cast(bytes->size()), + HB_MEMORY_MODE_READONLY, + bytes, + [](void* owner) { static_cast(owner)->unref(); }); + }, + typeface, + nullptr), + hb_face_destroy); + auto font = + std::shared_ptr(hb_font_create(face.get()), hb_font_destroy); + hb_ot_font_set_funcs(font.get()); + hb_font_make_immutable(font.get()); + return font; +} + +struct ShapedText { + Float width{}; + sk_sp blob; +}; + +ShapedText shapeEmoji(const SkFont& font, const std::string& text) { + if (text.size() > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Fantom text is too long to shape"); + } + std::shared_ptr shapingFont; + { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& registered : registry.fonts) { + if (registered.face.get() == font.getTypeface()) { + shapingFont = registered.shapingFont; + break; + } + } + } + if (!shapingFont) { + throw std::runtime_error("Cannot shape an unregistered Fantom font"); + } + const auto buffer = + std::unique_ptr( + hb_buffer_create(), hb_buffer_destroy); + hb_buffer_set_flags(buffer.get(), HB_BUFFER_FLAG_REMOVE_DEFAULT_IGNORABLES); + hb_buffer_add_utf8( + buffer.get(), text.data(), static_cast(text.size()), 0, -1); + hb_buffer_guess_segment_properties(buffer.get()); + hb_shape(shapingFont.get(), buffer.get(), nullptr, 0); + if (hb_buffer_allocation_successful(buffer.get()) == 0) { + throw std::runtime_error("Cannot allocate Fantom text shaping buffer"); + } + unsigned count = 0; + const auto* glyphInfo = hb_buffer_get_glyph_infos(buffer.get(), &count); + const auto* glyphPositions = + hb_buffer_get_glyph_positions(buffer.get(), nullptr); + if (count == 0) { + return {}; + } + if (glyphInfo == nullptr || glyphPositions == nullptr || + count > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Cannot read Fantom shaped glyphs"); + } + const auto glyphs = std::span(glyphInfo, count); + const auto positions = std::span(glyphPositions, count); + SkTextBlobBuilder builder; + const auto& run = builder.allocRunPos(font, static_cast(count)); + auto* points = run.points(); + if (run.glyphs == nullptr || points == nullptr) { + throw std::runtime_error("Cannot allocate Fantom shaped glyphs"); + } + const auto outputGlyphs = std::span(run.glyphs, count); + const auto outputPositions = std::span(points, count); + const auto scale = font.getSize() / + static_cast(hb_face_get_upem(hb_font_get_face(shapingFont.get()))); + Float width = 0; + for (unsigned i = 0; i < count; ++i) { + outputGlyphs[i] = static_cast(glyphs[i].codepoint); + outputPositions[i] = { + .fX = width + static_cast(positions[i].x_offset) * scale, + .fY = -static_cast(positions[i].y_offset) * scale}; + width += static_cast(positions[i].x_advance) * scale; + } + return {.width = width, .blob = builder.make()}; +} + +size_t nextTextUnitEnd(const std::string& text, size_t start) { + const auto bytes = std::span(text.data(), text.size()); + const auto* cursor = bytes.subspan(start).data(); + const auto* end = std::to_address(bytes.end()); + const auto codepoint = SkUTF::NextUTF8(&cursor, end); + if (codepoint < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + auto* next = cursor; + auto following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + if (following == 0xfe0e || following == 0xfe0f) { + cursor = next; + following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + } + if (codepoint == '#' || codepoint == '*' || + (codepoint >= '0' && codepoint <= '9')) { + if (following == 0x20e3) { + cursor = next; + } + } + return static_cast(cursor - text.data()); +} + +Float getFontSizeMultiplier(const TextAttributes& attributes) { + if (!attributes.allowFontScaling.value_or(true)) { + return 1; + } + const auto multiplier = std::isfinite(attributes.fontSizeMultiplier) + ? attributes.fontSizeMultiplier + : 1; + return std::isfinite(attributes.maxFontSizeMultiplier) && + attributes.maxFontSizeMultiplier >= 1 + ? std::min(multiplier, attributes.maxFontSizeMultiplier) + : multiplier; +} + +sk_sp typeface(const TextAttributes& attributes) { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + const auto family = + attributes.fontFamily.empty() ? defaultFontFamily : attributes.fontFamily; + const bool bold = attributes.fontWeight.value_or(FontWeight::Regular) >= + FontWeight::Semibold; + const bool italic = attributes.fontStyle == FontStyle::Italic; + sk_sp closest; + int closestDistance = std::numeric_limits::max(); + for (const auto& font : registry.fonts) { + if (font.family != family) { + continue; + } + const auto style = font.face->fontStyle(); + const auto distance = std::abs(style.weight() - (bold ? 700 : 400)) + + ((style.slant() != SkFontStyle::kUpright_Slant) != italic ? 1000 : 0); + if (distance < closestDistance) { + closest = font.face; + closestDistance = distance; + } + } + if (closest) { + return closest; + } + for (const auto& font : registry.fonts) { + if (font.family == defaultFontFamily && + font.face->fontStyle() == SkFontStyle::Normal()) { + return font.face; + } + } + throw std::runtime_error( + "Fantom requires its bundled default font before measuring text"); +} + +sk_sp emojiTypeface() { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& font : registry.fonts) { + if (font.family == emojiFontFamily) { + return font.face; + } + } + return nullptr; +} + +bool hasDefaultEmojiPresentation(SkUnichar codepoint) { + // Unicode 16.0 Emoji_Presentation. + static constexpr auto ranges = std::to_array>({ + {0x231a, 0x231b}, {0x23e9, 0x23ec}, {0x23f0, 0x23f0}, + {0x23f3, 0x23f3}, {0x25fd, 0x25fe}, {0x2614, 0x2615}, + {0x2648, 0x2653}, {0x267f, 0x267f}, {0x2693, 0x2693}, + {0x26a1, 0x26a1}, {0x26aa, 0x26ab}, {0x26bd, 0x26be}, + {0x26c4, 0x26c5}, {0x26ce, 0x26ce}, {0x26d4, 0x26d4}, + {0x26ea, 0x26ea}, {0x26f2, 0x26f3}, {0x26f5, 0x26f5}, + {0x26fa, 0x26fa}, {0x26fd, 0x26fd}, {0x2705, 0x2705}, + {0x270a, 0x270b}, {0x2728, 0x2728}, {0x274c, 0x274c}, + {0x274e, 0x274e}, {0x2753, 0x2755}, {0x2757, 0x2757}, + {0x2795, 0x2797}, {0x27b0, 0x27b0}, {0x27bf, 0x27bf}, + {0x2b1b, 0x2b1c}, {0x2b50, 0x2b50}, {0x2b55, 0x2b55}, + {0x1f004, 0x1f004}, {0x1f0cf, 0x1f0cf}, {0x1f18e, 0x1f18e}, + {0x1f191, 0x1f19a}, {0x1f1e6, 0x1f1ff}, {0x1f201, 0x1f201}, + {0x1f21a, 0x1f21a}, {0x1f22f, 0x1f22f}, {0x1f232, 0x1f236}, + {0x1f238, 0x1f23a}, {0x1f250, 0x1f251}, {0x1f300, 0x1f320}, + {0x1f32d, 0x1f335}, {0x1f337, 0x1f37c}, {0x1f37e, 0x1f393}, + {0x1f3a0, 0x1f3ca}, {0x1f3cf, 0x1f3d3}, {0x1f3e0, 0x1f3f0}, + {0x1f3f4, 0x1f3f4}, {0x1f3f8, 0x1f43e}, {0x1f440, 0x1f440}, + {0x1f442, 0x1f4fc}, {0x1f4ff, 0x1f53d}, {0x1f54b, 0x1f54e}, + {0x1f550, 0x1f567}, {0x1f57a, 0x1f57a}, {0x1f595, 0x1f596}, + {0x1f5a4, 0x1f5a4}, {0x1f5fb, 0x1f64f}, {0x1f680, 0x1f6c5}, + {0x1f6cc, 0x1f6cc}, {0x1f6d0, 0x1f6d2}, {0x1f6d5, 0x1f6d7}, + {0x1f6dc, 0x1f6df}, {0x1f6eb, 0x1f6ec}, {0x1f6f4, 0x1f6fc}, + {0x1f7e0, 0x1f7eb}, {0x1f7f0, 0x1f7f0}, {0x1f90c, 0x1f93a}, + {0x1f93c, 0x1f945}, {0x1f947, 0x1f9ff}, {0x1fa70, 0x1fa7c}, + {0x1fa80, 0x1fa89}, {0x1fa8f, 0x1fac6}, {0x1face, 0x1fadc}, + {0x1fadf, 0x1fae9}, {0x1faf0, 0x1faf8}, + }); + const auto range = std::lower_bound( + ranges.begin(), + ranges.end(), + codepoint, + [](const auto& bounds, SkUnichar value) { return bounds[1] < value; }); + return range != ranges.end() && (*range)[0] <= codepoint; +} + +bool hasColorEmoji(const std::string& text, const sk_sp& emoji) { + if (!emoji) { + return false; + } + const auto* cursor = text.data(); + const auto bytes = std::span(text.data(), text.size()); + const auto* end = std::to_address(bytes.end()); + while (cursor < end) { + const auto codepoint = SkUTF::NextUTF8(&cursor, end); + if (codepoint < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + const auto* next = cursor; + const auto following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + if (following != 0xfe0e && + (following == 0xfe0f || codepoint == 0x20e3 || + hasDefaultEmojiPresentation(codepoint)) && + emoji->unicharToGlyph(codepoint) != 0) { + return true; + } + } + return false; +} + +bool hasGlyphs(const SkFont& font, const std::string& text) { + const auto count = SkUTF::CountUTF8(text.data(), text.size()); + if (count < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + std::vector glyphs(static_cast(count)); + font.textToGlyphs( + text.data(), text.size(), SkTextEncoding::kUTF8, glyphs.data(), count); + return std::ranges::none_of(glyphs, [](auto glyph) { return glyph == 0; }); +} + +std::string fallbackFamily( + const std::string& character, + const TextAttributes& attributes) { + const auto* cursor = character.data(); + const auto bytes = std::span(character.data(), character.size()); + const auto codepoint = SkUTF::NextUTF8(&cursor, std::to_address(bytes.end())); + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& font : registry.fonts) { + if (font.face->unicharToGlyph(codepoint) != 0) { + return font.family; + } + } + return attributes.fontFamily; +} + +AttributedString resolveFontFallback( + SkCanvas* canvas, + const AttributedString& text) { + AttributedString resolved; + const auto emoji = emojiTypeface(); + for (const auto& fragment : text.getFragments()) { + const auto font = applyFont(canvas, fragment.textAttributes); + if (fragment.isAttachment() || + (hasGlyphs(font, fragment.string) && + !hasColorEmoji(fragment.string, emoji))) { + resolved.appendFragment(AttributedString::Fragment{fragment}); + continue; + } + auto run = fragment; + run.string.clear(); + for (size_t start = 0; start < fragment.string.size();) { + const auto end = nextTextUnitEnd(fragment.string, start); + const auto character = fragment.string.substr(start, end - start); + const auto family = hasColorEmoji(character, emoji) + ? std::string(emojiFontFamily) + : hasGlyphs(font, character) + ? fragment.textAttributes.fontFamily + : fallbackFamily(character, fragment.textAttributes); + if (family != run.textAttributes.fontFamily && !run.string.empty()) { + resolved.appendFragment(AttributedString::Fragment{run}); + run.string.clear(); + } + run.textAttributes.fontFamily = family; + run.string += character; + start = end; + } + if (!run.string.empty()) { + resolved.appendFragment(std::move(run)); + } + } + return resolved; +} + +} // namespace + +void registerFont(const std::string& filePath) { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + static const auto manager = SkFontMgr_New_Custom_Empty(); + auto face = manager->makeFromFile(filePath.c_str()); + if (!face) { + throw std::runtime_error("Cannot register Fantom font: " + filePath); + } + SkString family; + face->getFamilyName(&family); + auto shapingFont = std::string_view(family.c_str()) == emojiFontFamily + ? makeShapingFont(face.get()) + : nullptr; + registry.fonts.push_back( + {family.c_str(), std::move(face), std::move(shapingFont)}); +} + +SkFont applyFont(SkCanvas* /*canvas*/, const TextAttributes& attributes) { + SkFont font( + typeface(attributes), + (std::isfinite(attributes.fontSize) ? attributes.fontSize : 14) * + getFontSizeMultiplier(attributes)); + font.setEdging(SkFont::Edging::kAntiAlias); + font.setHinting(SkFontHinting::kNone); + font.setSubpixel(true); + font.setLinearMetrics(true); + font.setEmbeddedBitmaps(true); + return font; +} + +TextLayout layoutText( + SkCanvas* canvas, + const AttributedString& text, + const ParagraphAttributes& paragraph, + Float maximumWidth) { + TextLayout result; + TextLine line; + const auto maxLines = paragraph.maximumNumberOfLines > 0 + ? paragraph.maximumNumberOfLines + : std::numeric_limits::max(); + auto finishLine = [&] { + result.measurement.size.width = + std::max(result.measurement.size.width, line.width); + result.measurement.size.height += line.height; + result.lines.push_back(std::move(line)); + line = {}; + }; + + const auto resolved = resolveFontFallback(canvas, text); + for (const auto& fragment : resolved.getFragments()) { + const auto selectedFont = applyFont(canvas, fragment.textAttributes); + SkFontMetrics font{}; + selectedFont.getMetrics(&font); + const auto height = std::isfinite(fragment.textAttributes.lineHeight) + ? fragment.textAttributes.lineHeight * + getFontSizeMultiplier(fragment.textAttributes) + : static_cast((font.fDescent - font.fAscent + font.fLeading)); + const auto measure = [&](const std::string& token) -> ShapedText { + if (fragment.textAttributes.fontFamily == emojiFontFamily && + SkUTF::CountUTF8(token.data(), token.size()) > 1) { + return shapeEmoji(selectedFont, token); + } + return { + .width = selectedFont.measureText( + token.data(), token.size(), SkTextEncoding::kUTF8), + .blob = nullptr}; + }; + auto append = [&](const std::string& token, ShapedText shaped) { + line.runs.push_back( + {token, + fragment.textAttributes, + line.width, + shaped.width, + static_cast(-font.fAscent), + std::move(shaped.blob)}); + line.width += shaped.width; + line.height = std::max(line.height, height); + line.ascent = std::max(line.ascent, static_cast(-font.fAscent)); + }; + + if (fragment.isAttachment()) { + const auto size = fragment.parentShadowView.layoutMetrics.frame.size; + if (line.width > 0 && line.width + size.width > maximumWidth) { + finishLine(); + } + result.measurement.attachments.push_back( + {.frame = + {.origin = + {.x = line.width, .y = result.measurement.size.height}, + .size = size}, + .isClipped = static_cast(result.lines.size()) >= maxLines}); + line.width += size.width; + line.height = std::max(line.height, size.height); + continue; + } + + // Break at whitespace, then at UTF-8 character boundaries for long words. + for (size_t start = 0; start < fragment.string.size();) { + if (fragment.string[start] == '\n') { + line.height = std::max(line.height, height); + finishLine(); + ++start; + continue; + } + auto end = start + 1; + const bool whitespace = + fragment.string[start] == ' ' || fragment.string[start] == '\t'; + while (end < fragment.string.size() && fragment.string[end] != '\n' && + (fragment.string[end] == ' ' || fragment.string[end] == '\t') == + whitespace) { + ++end; + } + auto token = fragment.string.substr(start, end - start); + auto shaped = measure(token); + if (line.width > 0 && line.width + shaped.width > maximumWidth) { + finishLine(); + if (whitespace) { + start = end; + continue; + } + } + if (shaped.width <= maximumWidth || whitespace) { + append(token, std::move(shaped)); + } else { + for (size_t i = 0; i < token.size();) { + const auto next = nextTextUnitEnd(token, i); + auto character = token.substr(i, next - i); + shaped = measure(character); + if (line.width > 0 && line.width + shaped.width > maximumWidth) { + finishLine(); + } + append(character, std::move(shaped)); + i = next; + } + } + start = end; + } + } + if (!line.runs.empty() || line.height > 0) { + finishLine(); + } + if (static_cast(result.lines.size()) > maxLines) { + result.lines.resize(maxLines); + result.measurement.size = {}; + for (const auto& visibleLine : result.lines) { + result.measurement.size.width = + std::max(result.measurement.size.width, visibleLine.width); + result.measurement.size.height += visibleLine.height; + } + } + return result; +} + +void paintText(SkCanvas* canvas, const TextLayout& layout, Float width) { + if (canvas == nullptr) { + throw std::invalid_argument("Fantom requires a canvas to paint text"); + } + Float y = 0; + for (const auto& line : layout.lines) { + Float offset = 0; + if (!line.runs.empty()) { + const auto alignment = line.runs.front().attributes.alignment; + if (alignment == TextAlignment::Center) { + offset = (width - line.width) / 2; + } else if (alignment == TextAlignment::Right) { + offset = width - line.width; + } + } + for (const auto& run : line.runs) { + const auto font = applyFont(canvas, run.attributes); + const auto opacity = + std::isfinite(run.attributes.opacity) ? run.attributes.opacity : 1; + const auto paintFor = [&](SharedColor color) { + SkPaint paint; + paint.setAntiAlias(true); + paint.setColor(SkColorSetARGB( + alphaFromColor(color), + redFromColor(color), + greenFromColor(color), + blueFromColor(color))); + paint.setAlphaf(paint.getAlphaf() * opacity); + return paint; + }; + if (run.attributes.backgroundColor) { + canvas->drawRect( + SkRect::MakeXYWH(offset + run.x, y, run.width, line.height), + paintFor(run.attributes.backgroundColor)); + } + const auto color = run.attributes.foregroundColor + ? run.attributes.foregroundColor + : blackColor(); + const auto baseline = y + (line.height - line.ascent) / 2 + line.ascent; + if (run.blob) { + canvas->drawTextBlob( + run.blob, offset + run.x, baseline, paintFor(color)); + } else { + canvas->drawSimpleText( + run.text.data(), + run.text.size(), + SkTextEncoding::kUTF8, + offset + run.x, + baseline, + font, + paintFor(color)); + } + } + y += line.height; + } +} + +TextMeasurement FantomTextLayoutManager::measure( + const AttributedStringBox& text, + const ParagraphAttributes& paragraph, + const TextLayoutContext& /*context*/, + const LayoutConstraints& constraints) const { + auto result = + layoutText( + nullptr, text.getValue(), paragraph, constraints.maximumSize.width) + .measurement; + result.size = constraints.clamp(result.size); + return result; +} + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/TextLayout.h b/private/react-native-fantom/tester/src/render/TextLayout.h new file mode 100644 index 000000000000..31ee898572cc --- /dev/null +++ b/private/react-native-fantom/tester/src/render/TextLayout.h @@ -0,0 +1,59 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace facebook::react { + +struct TextRun { + std::string text; + TextAttributes attributes; + Float x{}; + Float width{}; + Float ascent{}; + sk_sp blob; +}; + +struct TextLine { + std::vector runs; + Float width{}; + Float height{}; + Float ascent{}; +}; + +struct TextLayout { + std::vector lines; + TextMeasurement measurement; +}; + +using TextCanvas = SkCanvas; +SkFont applyFont(TextCanvas *canvas, const TextAttributes &attributes); + +void registerFont(const std::string &filePath); +TextLayout +layoutText(TextCanvas *canvas, const AttributedString &text, const ParagraphAttributes ¶graph, Float maximumWidth); +void paintText(TextCanvas *canvas, const TextLayout &layout, Float width); + +class FantomTextLayoutManager : public TextLayoutManager { + public: + using TextLayoutManager::TextLayoutManager; + TextMeasurement measure( + const AttributedStringBox &text, + const ParagraphAttributes ¶graph, + const TextLayoutContext &context, + const LayoutConstraints &constraints) const override; +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/stubs/FantomTextInput.h b/private/react-native-fantom/tester/src/stubs/FantomTextInput.h new file mode 100644 index 000000000000..8510ea6be96a --- /dev/null +++ b/private/react-native-fantom/tester/src/stubs/FantomTextInput.h @@ -0,0 +1,85 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include +#include "../render/TextLayout.h" + +namespace facebook::react { + +class FantomTextInputProps : public BaseTextInputProps { + public: + FantomTextInputProps() = default; + FantomTextInputProps(const PropsParserContext &context, const FantomTextInputProps &source, const RawProps &raw) + : BaseTextInputProps(context, source, raw), + secureTextEntry(convertRawProp(context, raw, "secureTextEntry", source.secureTextEntry, false)) + { + } + bool secureTextEntry{}; +}; + +inline constexpr char FantomAndroidTextInputName[] = "AndroidTextInput"; +inline constexpr char FantomSinglelineTextInputName[] = "RCTSinglelineTextInputView"; +inline constexpr char FantomMultilineTextInputName[] = "RCTMultilineTextInputView"; + +template +class FantomTextInputShadowNode : public ConcreteViewShadowNode { + public: + using Base = ConcreteViewShadowNode; + using Base::Base; + static ShadowNodeTraits BaseTraits() + { + auto traits = Base::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + std::shared_ptr textLayoutManager; + + protected: + Size measureContent(const LayoutContext &context, const LayoutConstraints &constraints) const override + { + const auto &props = this->getConcreteProps(); + AttributedString text; + text.appendFragment( + {.string = props.text.empty() ? props.placeholder : props.text, + .textAttributes = props.getEffectiveTextAttributes(context.fontSizeMultiplier), + .parentShadowView = {}}); + auto paragraph = props.paragraphAttributes; + if (!props.multiline) { + paragraph.maximumNumberOfLines = 1; + } + return textLayoutManager + ->measure( + AttributedStringBox{text}, + paragraph, + {.pointScaleFactor = context.pointScaleFactor, .surfaceId = this->getSurfaceId()}, + constraints) + .size; + } +}; + +template +class FantomTextInputComponentDescriptor : public ConcreteComponentDescriptor> { + public: + using Base = ConcreteComponentDescriptor>; + using Base::Base; + + protected: + void adopt(ShadowNode &node) const override + { + Base::adopt(node); + static_cast &>(node).textLayoutManager = + this->contextContainer_->template at>("TextLayoutManager"); + } +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h b/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h index b68623060fa9..b273736c5293 100644 --- a/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h +++ b/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h @@ -7,6 +7,8 @@ #pragma once +#include "FantomTextInput.h" + #include #include #include @@ -20,11 +22,13 @@ namespace facebook::react { -inline ComponentRegistryFactory getDefaultComponentRegistryFactory() +inline ComponentRegistryFactory getDefaultComponentRegistryFactory(std::shared_ptr textLayoutManager) { - return [](const EventDispatcher::Weak &eventDispatcher, - const std::shared_ptr &contextContainer) { - static auto providerRegistry = []() { + return [textLayoutManager = std::move(textLayoutManager)]( + const EventDispatcher::Weak &eventDispatcher, + const std::shared_ptr &contextContainer) { + contextContainer->insert("TextLayoutManager", textLayoutManager); + static const auto providerRegistry = []() { auto providerRegistry = std::make_shared(); providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); @@ -34,9 +38,16 @@ inline ComponentRegistryFactory getDefaultComponentRegistryFactory() providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); return providerRegistry; }(); - return providerRegistry->createComponentDescriptorRegistry({eventDispatcher, contextContainer, nullptr}); + return providerRegistry->createComponentDescriptorRegistry( + {.eventDispatcher = eventDispatcher, .contextContainer = contextContainer, .flavor = nullptr}); }; } diff --git a/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt b/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt new file mode 100644 index 000000000000..07334cf4fa00 --- /dev/null +++ b/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt @@ -0,0 +1,13 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +set(BUILD_TESTING OFF) +set(BUILD_SHARED_LIBS OFF) +add_subdirectory( + "${FANTOM_THIRD_PARTY_DIR}/boringssl" + "${CMAKE_CURRENT_BINARY_DIR}/boringssl" + EXCLUDE_FROM_ALL +) +add_library(OpenSSL::Crypto ALIAS crypto) diff --git a/private/react-native-fantom/tester/third-party/folly/CMakeLists.txt b/private/react-native-fantom/tester/third-party/folly/CMakeLists.txt index e2af851f140a..1a0312567372 100644 --- a/private/react-native-fantom/tester/third-party/folly/CMakeLists.txt +++ b/private/react-native-fantom/tester/third-party/folly/CMakeLists.txt @@ -38,6 +38,9 @@ SET(folly_runtime_SRC folly/detail/Futex.cpp folly/detail/SplitStringSimd.cpp folly/detail/UniqueInstance.cpp + folly/detail/base64_detail/Base64Api.cpp + folly/detail/base64_detail/Base64SWAR.cpp + folly/detail/base64_detail/Base64_SSE4_2.cpp folly/hash/SpookyHashV2.cpp folly/io/IOBuf.cpp folly/json/dynamic.cpp diff --git a/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt b/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt new file mode 100644 index 000000000000..1dbd1b741305 --- /dev/null +++ b/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt @@ -0,0 +1,66 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +include(ExternalProject) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +find_package(Threads REQUIRED) + +set(SKIA_SOURCE_DIR "${FANTOM_THIRD_PARTY_DIR}/skia") +set(SKIA_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/out") +set(SKIA_RASTER_PIPELINE_HEADER "${SKIA_SOURCE_DIR}/src/opts/SkRasterPipeline_opts.h") +file(READ "${SKIA_RASTER_PIPELINE_HEADER}" SKIA_RASTER_PIPELINE_SOURCE) +set(SKIA_SCALAR_SELECTION "#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)\n #define JUMPER_IS_SCALAR") +string(FIND "${SKIA_RASTER_PIPELINE_SOURCE}" "${SKIA_SCALAR_SELECTION}" SKIA_SCALAR_SELECTION_INDEX) +if(SKIA_SCALAR_SELECTION_INDEX EQUAL -1) + message(FATAL_ERROR "Fantom requires Skia's scalar raster pipeline patch from prepareSkiaSource") +endif() +set(BUILD_SHARED_LIBS OFF) +set(HB_BUILD_SUBSET OFF CACHE BOOL "Build only the HarfBuzz shaping library") +set(HB_HAVE_CORETEXT OFF CACHE BOOL "Use OpenType shaping on every platform") +add_subdirectory("${SKIA_SOURCE_DIR}/third_party/externals/harfbuzz" harfbuzz) +if(NOT FANTOM_NINJA) + find_program(FANTOM_NINJA ninja REQUIRED) +endif() + +set(SKIA_GN_ARGS + "is_official_build=true is_debug=false is_component_build=false" + "skia_enable_gpu=false skia_enable_graphite=false skia_enable_pdf=false" + "skia_enable_tools=false skia_enable_skottie=false skia_enable_svg=false" + "skia_use_fontconfig=false skia_use_fonthost_mac=false skia_use_freetype=true" + "skia_enable_fontmgr_custom_directory=false skia_enable_fontmgr_custom_embedded=false" + "skia_enable_fontmgr_custom_empty=true skia_enable_fontmgr_win=false" + "skia_enable_fontmgr_win_gdi=false skia_use_harfbuzz=false skia_use_icu=false" + "skia_use_fixed_gamma_text=false" + "extra_cflags=[\"-DSK_GAMMA_EXPONENT=1.0\",\"-DSK_GAMMA_CONTRAST=0.0\",\"-DSK_DISABLE_LEGACY_SHADERCONTEXT\",\"-DSK_DISABLE_LOWP_RASTER_PIPELINE\",\"-DSK_FORCE_RASTER_PIPELINE_BLITTER\",\"-DSK_RASTER_PIPELINE_FORCE_SCALAR\",\"-ffp-contract=off\"]" + "skia_use_expat=false skia_use_piex=false skia_use_dng_sdk=false" + "skia_use_libjpeg_turbo_decode=false skia_use_libjpeg_turbo_encode=false" + "skia_use_libwebp_decode=false skia_use_libwebp_encode=false skia_use_libgifcodec=false" + "skia_use_system_freetype2=false skia_use_system_libpng=false skia_use_system_zlib=false" + "cc=\"${CMAKE_C_COMPILER}\" cxx=\"${CMAKE_CXX_COMPILER}\"" +) +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + list(APPEND SKIA_GN_ARGS "is_clang=true") +else() + list(APPEND SKIA_GN_ARGS "is_clang=false") +endif() +string(JOIN " " SKIA_GN_ARGS ${SKIA_GN_ARGS}) + +ExternalProject_Add(fantom_skia_build + SOURCE_DIR "${SKIA_SOURCE_DIR}" + DOWNLOAD_COMMAND "${Python3_EXECUTABLE}" "${SKIA_SOURCE_DIR}/bin/fetch-gn" + CONFIGURE_COMMAND "${SKIA_SOURCE_DIR}/bin/gn" gen "${SKIA_BUILD_DIR}" "--root=${SKIA_SOURCE_DIR}" "--args=${SKIA_GN_ARGS}" + BUILD_COMMAND "${FANTOM_NINJA}" -C "${SKIA_BUILD_DIR}" skia + INSTALL_COMMAND "" + BUILD_BYPRODUCTS "${SKIA_BUILD_DIR}/libskia.a" +) +target_include_directories(harfbuzz INTERFACE "${SKIA_SOURCE_DIR}/third_party/externals/harfbuzz/src") +add_library(fantom_skia STATIC IMPORTED GLOBAL) +set_target_properties(fantom_skia PROPERTIES + IMPORTED_LOCATION "${SKIA_BUILD_DIR}/libskia.a" + INTERFACE_INCLUDE_DIRECTORIES "${SKIA_SOURCE_DIR}" + INTERFACE_COMPILE_DEFINITIONS FANTOM_SKIA_FACTORY_PROVIDED + INTERFACE_LINK_LIBRARIES "Threads::Threads;harfbuzz" +) +add_dependencies(fantom_skia fantom_skia_build) diff --git a/yarn.lock b/yarn.lock index a0e1e91d5eb1..ee9f7e6f5105 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,6 +1242,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== +"@expo-google-fonts/noto-color-emoji@0.4.7": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@expo-google-fonts/noto-color-emoji/-/noto-color-emoji-0.4.7.tgz#9d3fe605309fb5cd554b145c870c12b38dbde772" + integrity sha512-0jZUhh9r92VouxJ5z59Vxw1BGjbaat7EEEasmsLI+doe5hXS1/d4oZUyfyv/TFvkwuZjqvbDh6WdYM9q0ZhiyA== + "@expo/spawn-async@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" @@ -3798,6 +3803,11 @@ define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +dejavu-fonts-ttf@2.37.3: + version "2.37.3" + resolved "https://registry.yarnpkg.com/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz#23f2bf5af594ca2a8a3a53224224b03467a6e0b8" + integrity sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ== + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"