Skip to content

Commit afbe3be

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Add Skia screenshots for Fantom roots
Summary: Integrate screenshots into Fantom's existing Jest snapshot workflow so tests catch visual regressions while PNG baselines remain easy to inspect. Immutable captures make comparisons across React updates reliable. Share Skia and fonts with text measurement to keep layout and painting consistent across Linux and macOS. Pixel hashes make comparisons independent of PNG encoding; saved captures and highlighted differences make failures inspectable in CI and terminals without image support. Changelog: [Internal] Add Skia screenshot snapshots to Fantom. Differential Revision: D126888504
1 parent 4b44ba9 commit afbe3be

67 files changed

Lines changed: 4065 additions & 91 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎flow-typed/npm/pngjs_v7.x.x.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @flow strict
3+
* @format
4+
*/
5+
6+
declare module 'pngjs/lib/png' {
7+
declare export class PNG {
8+
constructor(options: {width: number, height: number}): void;
9+
width: number;
10+
height: number;
11+
data: Buffer;
12+
static sync: {
13+
read(buffer: Buffer): PNG,
14+
write(image: PNG): Buffer,
15+
};
16+
}
17+
}

‎packages/new-app-screen/src/NewAppScreen.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import Links from './Links';
12+
import RuntimeVersion from './RuntimeVersion';
1213
import {ThemedText, useTheme} from './Theme';
1314
import * as React from 'react';
1415
import {
@@ -135,8 +136,7 @@ function getHermesLabel(): React.Node {
135136

136137
return (
137138
<ThemedText color="secondary" style={styles.label}>
138-
JS Engine: Hermes (
139-
{global.HermesInternal.getRuntimeProperties?.()['OSS Release Version']})
139+
JS Engine: Hermes ({RuntimeVersion.hermes})
140140
</ThemedText>
141141
);
142142
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
const RuntimeVersion: {hermes: string | void} = {
12+
hermes:
13+
global.HermesInternal?.getRuntimeProperties?.()?.['OSS Release Version'],
14+
};
15+
16+
export default RuntimeVersion;

‎packages/react-native/src/private/testing/fantom/specs/NativeFantom.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
RootTag,
1313
TurboModule,
1414
} from '../../../../../Libraries/TurboModule/RCTExport';
15+
import type {UnsafeObject} from '../../../../../Libraries/Types/CodegenTypes';
1516

1617
import * as TurboModuleRegistry from '../../../../../Libraries/TurboModule/TurboModuleRegistry';
1718

@@ -116,6 +117,7 @@ interface Spec extends TurboModule {
116117
surfaceId: RootTag,
117118
config: RenderFormatOptions,
118119
) => string;
120+
takeScreenshot(surfaceId: RootTag): UnsafeObject;
119121
registerAsset(uri: string, filePath: string): void;
120122
registerFont(filePath: string): void;
121123
reportTestSuiteResultsJSON: (results: string) => void;

‎private/react-native-fantom/__docs__/README.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,49 @@ Similar to Jest, you can also run Fantom in watch mode using `--watch`:
124124
yarn fantom <regexForTestFiles> --watch
125125
```
126126

127+
### PNG screenshots
128+
129+
`root.takeScreenshot()` captures the currently mounted Fabric revision into an
130+
immutable `Screenshot`. Capture after `runTask` to include its mounted updates;
131+
capture itself does not flush pending React work. The bitmap remains usable
132+
after later updates or root destruction.
133+
134+
```javascript
135+
const root = Fantom.createRoot();
136+
Fantom.runTask(() => root.render(<Screen />));
137+
const screenshot = root.takeScreenshot();
138+
expect(screenshot).toMatchSnapshot();
139+
expect(screenshot.isEqual(root.takeScreenshot())).toBe(true);
140+
```
141+
142+
The headless renderer paints Yoga's mounted frames with CPU Skia. Text uses the
143+
same layout and bundled fonts as intrinsic measurement, so the measured sizes
144+
and painted content agree. `isEqual()` compares bitmap dimensions and pixels in
145+
memory.
146+
147+
`toMatchSnapshot()` compares a SHA-256 hash of the bitmap dimensions and pixels
148+
with the value in Jest's `.snap` file. The expected PNG is not read for
149+
comparison. The runner also keeps an inspectable image in
150+
`__snapshots__/<test-file>.screenshots/`, named from the test, label and
151+
assertion number. Capture defers PNG encoding until an image must be saved;
152+
encoded bytes are cached for repeated saves. A matching hash passes even if the
153+
PNG is missing, and the runner regenerates that image.
154+
155+
`toMatchInlineSnapshot()` embeds the screenshot's type and hash directly in the
156+
test source. It does not encode or save an image, so inline failures report a
157+
text diff.
158+
159+
New snapshots are saved during a normal local run. `--updateSnapshot` (`-u`)
160+
accepts changed hashes and PNGs together; CI requires an existing matching hash.
161+
A mismatch leaves the baseline unchanged, saves a difference image alongside the
162+
received image, and reports all three image paths for inspection. Unused images
163+
are removed only during a successful snapshot update with no skipped or filtered
164+
tests. Negated snapshot assertions are unsupported.
165+
166+
See [Rendering](rendering.md) for the painting architecture, supported features,
167+
font selection and the configuration that keeps shared baselines consistent
168+
across Linux and macOS.
169+
127170
### Interactive REPL
128171

129172
> [!WARNING]

‎private/react-native-fantom/__docs__/rendering.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,61 @@ The scalar selector is an optional `SK_RASTER_PIPELINE_FORCE_SCALAR` definition
7474
in the pinned Skia source. Gradle patches its prepared source copy, and CMake
7575
validates the selector without modifying the source. Keep the internal target's
7676
flags and the public GN flags aligned when changing Skia.
77+
78+
## Screenshot snapshots
79+
80+
`root.takeScreenshot()` paints the currently mounted revision into an immutable
81+
CPU bitmap. It does not flush pending React work. Captures survive later commits
82+
and root destruction. Use `isEqual()` to compare their dimensions and pixel rows
83+
in memory. `saveToFile()` writes a PNG, creating parent directories and
84+
replacing an existing file.
85+
86+
`toMatchSnapshot()` stores a SHA-256 hash of the image dimensions and
87+
premultiplied RGBA pixel rows. It compares that hash with the ordinary Jest
88+
snapshot value; it does not decode an expected PNG. An inspectable PNG is kept
89+
beside the `.snap` file in `<test-file>.screenshots`. PNG encoding is deferred
90+
until an image must be saved, and PNG bytes are cached for repeated saves. A
91+
matching hash passes even when the standalone PNG is missing; the runner
92+
recreates that image from the matching capture without updating the hash.
93+
94+
`toMatchInlineSnapshot()` embeds the same `{type: 'Screenshot', hash}` value in
95+
the test source. It compares hashes without encoding or saving PNGs, including
96+
when the inline snapshot is created, updated or fails. Inline failures use
97+
ordinary text snapshot diagnostics.
98+
99+
Snapshot mismatches retain the received PNG and save a difference PNG in the
100+
same temporary directory. Failure diagnostics include the paths to the expected,
101+
received and difference images, including in CI and redirected output. The
102+
difference highlights changed pixels in red over faded unchanged content; it
103+
does not affect hash comparison. Missing or unreadable images retain their
104+
original diagnostics without a difference image. Snapshot updates copy it into
105+
the baseline directory before cleaning up the temporary file. Unused images are
106+
removed only during a full successful snapshot update; empty, failed, skipped or
107+
filtered suites preserve their baselines.
108+
109+
### Painting
110+
111+
The painter traverses Fabric's mounted child order, including stacking changes.
112+
Yoga supplies the frames for flow and absolute positioning. Overflow clips
113+
descendants to the view's rounded bounds; each view restores its canvas state
114+
before painting siblings. Nonidentity affine transforms are centered on the
115+
view, while identity transforms preserve child coordinates without extra
116+
floating-point translations. Singular transforms are skipped.
117+
118+
`View`, styled and nested `Text`, `TextInput`, `Image` and `ScrollView` use this
119+
shared canvas. Text inputs paint values or placeholders, including multiline and
120+
secure entry. Scroll views offset their content to show the current viewport.
121+
Backgrounds, solid borders, rounded corners and opacity follow the mounted
122+
props.
123+
124+
Images support local PNG files (`file://` or filesystem paths) and base64 PNG
125+
data URIs, with resize modes and tint. Supply network images as local fixtures.
126+
The shared asset resolver maps bundled `require()` sources to native files while
127+
keeping their React Native URIs stable in text snapshots.
128+
129+
An empty root produces a transparent viewport; a destroyed root cannot be
130+
captured. Viewport offsets do not move the screenshot's origin. The renderer
131+
does not reproduce Android or iOS platform chrome, shadows or perspective
132+
transforms. Additional font fixtures can be registered with
133+
`NativeFantom.registerFont()` before mounting; the default fonts need no
134+
per-test registration.

‎private/react-native-fantom/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "./build.sh"
99
},
1010
"dependencies": {
11-
"babel-plugin-istanbul": "^7.0.0"
11+
"babel-plugin-istanbul": "^7.0.0",
12+
"pngjs": "7.0.0"
1213
},
1314
"devDependencies": {
1415
"@expo-google-fonts/noto-color-emoji": "0.4.7",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import {updateSnapshotsAndGetJestSnapshotResult} from '../snapshotUtils';
12+
import {SnapshotState} from 'jest-snapshot';
13+
import fs from 'node:fs';
14+
import os from 'node:os';
15+
import path from 'node:path';
16+
17+
let directory: string;
18+
beforeEach(() => {
19+
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fantom-snapshot-test-'));
20+
});
21+
afterEach(() => fs.rmSync(directory, {recursive: true, force: true}));
22+
23+
it('does not overwrite an existing snapshot on mismatch without -u', () => {
24+
const filePath = path.join(directory, 'example.snap');
25+
fs.writeFileSync(filePath, 'exports[`test 1`] = `old hash`;\n');
26+
for (const mode of ['new', 'none'] as const) {
27+
const state = new SnapshotState(filePath, {
28+
updateSnapshot: mode,
29+
rootDir: directory,
30+
snapshotFormat: {},
31+
});
32+
const result = updateSnapshotsAndGetJestSnapshotResult(state, [
33+
{
34+
status: 'failed',
35+
snapshotResults: {'test 1': {pass: false, value: 'new hash'}},
36+
},
37+
]);
38+
expect(result.unmatched).toBe(1);
39+
expect(result.updated).toBe(0);
40+
expect(fs.readFileSync(filePath, 'utf8')).toContain('old hash');
41+
expect(fs.readFileSync(filePath, 'utf8')).not.toContain('new hash');
42+
}
43+
});
44+
45+
it('updates a hash once with -u and counts passing snapshots once', () => {
46+
const filePath = path.join(directory, 'example.snap');
47+
fs.writeFileSync(filePath, 'exports[`test 1`] = `old hash`;\n');
48+
const state = new SnapshotState(filePath, {
49+
updateSnapshot: 'all',
50+
rootDir: directory,
51+
snapshotFormat: {},
52+
});
53+
const result = updateSnapshotsAndGetJestSnapshotResult(state, [
54+
{
55+
status: 'passed',
56+
snapshotResults: {'test 1': {pass: false, value: 'new hash'}},
57+
},
58+
]);
59+
expect(result.updated).toBe(1);
60+
expect(fs.readFileSync(filePath, 'utf8')).toContain('new hash');
61+
const matching = new SnapshotState(filePath, {
62+
updateSnapshot: 'none',
63+
rootDir: directory,
64+
snapshotFormat: {},
65+
});
66+
const matched = updateSnapshotsAndGetJestSnapshotResult(matching, [
67+
{
68+
status: 'passed',
69+
snapshotResults: {'test 1': {pass: true}},
70+
},
71+
]);
72+
expect(matched.matched).toBe(1);
73+
expect(matched.unchecked).toBe(0);
74+
});
75+
76+
it('counts a new snapshot once', () => {
77+
const state = new SnapshotState(path.join(directory, 'example.snap'), {
78+
updateSnapshot: 'new',
79+
rootDir: directory,
80+
snapshotFormat: {},
81+
});
82+
const result = updateSnapshotsAndGetJestSnapshotResult(state, [
83+
{
84+
status: 'passed',
85+
snapshotResults: {'test 1': {pass: false, value: 'new hash'}},
86+
},
87+
]);
88+
expect(result.added).toBe(1);
89+
expect(result.matched).toBe(0);
90+
expect(result.updated).toBe(0);
91+
expect(result.unmatched).toBe(0);
92+
});
93+
94+
it('preserves unchecked snapshots when no tests ran with -u', () => {
95+
const filePath = path.join(directory, 'example.snap');
96+
const contents =
97+
'// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`test 1`] = `old hash`;\n';
98+
fs.writeFileSync(filePath, contents);
99+
const state = new SnapshotState(filePath, {
100+
updateSnapshot: 'all',
101+
rootDir: directory,
102+
snapshotFormat: {},
103+
});
104+
const result = updateSnapshotsAndGetJestSnapshotResult(state, []);
105+
expect(fs.readFileSync(filePath, 'utf8')).toBe(contents);
106+
expect(result.unchecked).toBe(0);
107+
expect(result.uncheckedKeys).toEqual([]);
108+
});
109+
110+
it('preserves unchecked snapshots after skipped or failed tests with -u', () => {
111+
const filePath = path.join(directory, 'example.snap');
112+
const contents =
113+
'// 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';
114+
for (const status of ['pending', 'failed'] as const) {
115+
fs.writeFileSync(filePath, contents);
116+
const state = new SnapshotState(filePath, {
117+
updateSnapshot: 'all',
118+
rootDir: directory,
119+
snapshotFormat: {},
120+
});
121+
const result = updateSnapshotsAndGetJestSnapshotResult(state, [
122+
{
123+
status: 'passed',
124+
snapshotResults: {'test 1': {pass: true}},
125+
},
126+
{status, snapshotResults: {}},
127+
]);
128+
expect(fs.readFileSync(filePath, 'utf8')).toBe(contents);
129+
expect(result.unchecked).toBe(0);
130+
expect(result.uncheckedKeys).toEqual([]);
131+
}
132+
});
133+
134+
it('removes unchecked snapshots after a full successful update', () => {
135+
const filePath = path.join(directory, 'example.snap');
136+
fs.writeFileSync(
137+
filePath,
138+
'exports[`test 1`] = `old hash`;\nexports[`removed test 1`] = `removed hash`;\n',
139+
);
140+
const state = new SnapshotState(filePath, {
141+
updateSnapshot: 'all',
142+
rootDir: directory,
143+
snapshotFormat: {},
144+
});
145+
updateSnapshotsAndGetJestSnapshotResult(state, [
146+
{
147+
status: 'passed',
148+
snapshotResults: {'test 1': {pass: true}},
149+
},
150+
]);
151+
expect(fs.readFileSync(filePath, 'utf8')).toContain('old hash');
152+
expect(fs.readFileSync(filePath, 'utf8')).not.toContain('removed hash');
153+
});

0 commit comments

Comments
 (0)