Skip to content

Commit ba24574

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Preview failed Fantom screenshot snapshots in terminals
Summary: Make visual regressions easier to inspect with Expected | Received | Difference previews and file paths. A Jest reporter reuses the saved PNGs without changing snapshot comparison. Select the protocol from the detected terminal and confirm VS Code image support before sending graphics. Keep full text diagnostics whenever a required preview cannot be shown; CI, unsupported terminals and redirected output retain text and file paths. Changelog: [Internal] Show terminal previews for failing Fantom screenshot snapshots. Differential Revision: D127129237
1 parent 3d38b38 commit ba24574

16 files changed

Lines changed: 1359 additions & 10 deletions

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ the baseline directory before cleaning up the temporary file. Unused images are
106106
removed only during a full successful snapshot update; empty, failed, skipped or
107107
filtered suites preserve their baselines.
108108

109+
Failing screenshot snapshots show expected, received and diff PNG previews in
110+
supported terminals instead of hash diffs, with file paths beneath the images.
111+
Wide terminals show Expected, Received and Difference side by side; narrow
112+
terminals stack them. Fantom selects the image protocol from the detected
113+
terminal. VS Code requires `terminal.integrated.enableImages` in its settings;
114+
Fantom checks the terminal's response before sending images. If that check
115+
cannot confirm support (including when another program is reading terminal
116+
input), it keeps text diagnostics and file paths without printing image headers.
117+
Unsupported terminals, terminal multiplexers, CI and redirected output retain
118+
text diagnostics and file paths. Full text diagnostics also remain when any
119+
required image preview is unavailable.
120+
109121
### Painting
110122

111123
The painter traverses Fabric's mounted child order, including stacking changes.

‎private/react-native-fantom/config/jest.config.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ module.exports = {
5454
testPathIgnorePatterns: baseConfig.testPathIgnorePatterns,
5555
transformIgnorePatterns: ['.*'],
5656
testRunner: '<rootDir>/private/react-native-fantom/runner/index.js',
57+
reporters: [
58+
'<rootDir>/private/react-native-fantom/reporter/index.js',
59+
'default',
60+
],
5761
watchPathIgnorePatterns: ['<rootDir>/private/react-native-fantom/build/'],
5862
globalSetup:
5963
'<rootDir>/private/react-native-fantom/runner/global-setup/setup.js',
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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 type {ScreenshotResult} from '../runtime/ScreenshotContext';
12+
import type {TerminalEnvironment} from './terminalImages';
13+
14+
import createImageRow from './imageRow';
15+
import queryImageSupport from './terminalCapabilities';
16+
import {encodeTerminalImage, getImageProtocol} from './terminalImages';
17+
import fs from 'node:fs';
18+
import tty from 'node:tty';
19+
20+
export type TestResult = {
21+
failureMessage?: ?string,
22+
readonly screenshotFailureMessage?: ?string,
23+
readonly testResults: ReadonlyArray<
24+
Readonly<{
25+
fullName: string,
26+
status: string,
27+
screenshotResults?: ReadonlyArray<ScreenshotResult>,
28+
}>,
29+
>,
30+
};
31+
32+
interface TerminalOutput {
33+
readonly isTTY?: boolean;
34+
readonly columns?: number;
35+
readonly rows?: number;
36+
write(string): unknown;
37+
}
38+
39+
export default class ScreenshotReporter {
40+
#vsCodeImages: ?Promise<boolean> = null;
41+
42+
onRunStart(): void {
43+
this.#vsCodeImages = null;
44+
}
45+
46+
async onTestResult(_test: unknown, result: TestResult): Promise<void> {
47+
const input = process.stdin;
48+
const output = process.stderr;
49+
if (output instanceof tty.WriteStream) {
50+
let supportsVSCodeImages = false;
51+
if (
52+
process.env.TERM_PROGRAM === 'vscode' &&
53+
input instanceof tty.ReadStream &&
54+
getImageProtocol(output.isTTY, process.env, true) != null &&
55+
result.testResults.some(
56+
test =>
57+
test.status === 'failed' &&
58+
test.screenshotResults?.some(
59+
screenshot => !screenshot.pass && screenshot.receivedPath != null,
60+
),
61+
)
62+
) {
63+
this.#vsCodeImages ??= queryImageSupport(input, output);
64+
supportsVSCodeImages = await this.#vsCodeImages;
65+
}
66+
reportScreenshotPreviews(
67+
result,
68+
output,
69+
process.env,
70+
supportsVSCodeImages,
71+
);
72+
}
73+
}
74+
}
75+
76+
export function reportScreenshotPreviews(
77+
result: TestResult,
78+
output: TerminalOutput,
79+
env: TerminalEnvironment,
80+
supportsVSCodeImages: boolean = false,
81+
): void {
82+
const protocol = getImageProtocol(output.isTTY, env, supportsVSCodeImages);
83+
if (protocol == null) {
84+
return;
85+
}
86+
const readErrors = new Map<string, string>();
87+
const readImage = (file: string): ?Buffer => {
88+
try {
89+
return fs.readFileSync(file);
90+
} catch (error: unknown) {
91+
const message = error instanceof Error ? error.message : String(error);
92+
readErrors.set(file, message.replace(/\p{Cc}/gu, ''));
93+
return null;
94+
}
95+
};
96+
const columns =
97+
output.columns != null && output.columns > 0 ? output.columns : 80;
98+
const rows = output.rows != null && output.rows > 0 ? output.rows : 30;
99+
const horizontal = columns >= 60;
100+
const preview = (png: Buffer) =>
101+
encodeTerminalImage(png, protocol, {
102+
columns: columns - 2,
103+
rows: rows - 9,
104+
tmux: env.TMUX != null,
105+
});
106+
let previews = '';
107+
let hasPreview = false;
108+
let allPreviewsRendered = true;
109+
for (const test of result.testResults) {
110+
if (test.status !== 'failed') {
111+
continue;
112+
}
113+
for (const screenshot of test.screenshotResults ?? []) {
114+
const receivedPath = screenshot.receivedPath;
115+
if (screenshot.pass || receivedPath == null) {
116+
continue;
117+
}
118+
const panels = [
119+
{
120+
label: 'Expected',
121+
path: screenshot.expectedPath,
122+
image: screenshot.isNew ? null : readImage(screenshot.expectedPath),
123+
unavailable: screenshot.isNew ? 'no baseline' : 'preview unavailable',
124+
required: !screenshot.isNew,
125+
},
126+
{
127+
label: 'Received',
128+
path: receivedPath,
129+
image: readImage(receivedPath),
130+
unavailable: 'preview unavailable',
131+
required: true,
132+
},
133+
{
134+
label: 'Difference',
135+
path: screenshot.differencePath,
136+
image:
137+
screenshot.differencePath == null
138+
? null
139+
: readImage(screenshot.differencePath),
140+
unavailable: screenshot.isNew ? 'no baseline' : 'preview unavailable',
141+
required: !screenshot.isNew,
142+
},
143+
];
144+
const title = test.fullName.replace(/\p{Cc}/gu, '');
145+
previews += `\nScreenshot snapshot: ${title}\n`;
146+
const images = panels
147+
.map(panel => panel.image)
148+
.filter(image => image != null);
149+
const row =
150+
horizontal && images.length === panels.length
151+
? createImageRow(images)
152+
: null;
153+
const rowPreview = row == null ? null : preview(row);
154+
if (rowPreview != null) {
155+
hasPreview = true;
156+
const labelWidth = Math.max(
157+
8,
158+
Math.floor((rowPreview.columns - 6) / 3),
159+
);
160+
previews +=
161+
panels.map(panel => panel.label.padEnd(labelWidth)).join(' | ') +
162+
'\n';
163+
previews += rowPreview.sequence + '\n';
164+
for (const panel of panels) {
165+
previews += `${panel.label}: ${panel.path ?? panel.unavailable}\n`;
166+
}
167+
} else {
168+
for (const panel of panels) {
169+
previews += `${panel.label}: ${panel.path ?? panel.unavailable}\n`;
170+
const image = panel.image == null ? null : preview(panel.image);
171+
hasPreview ||= image != null;
172+
allPreviewsRendered &&= image != null || !panel.required;
173+
const readError = readErrors.get(panel.path ?? '');
174+
previews +=
175+
(image?.sequence ??
176+
(readError == null
177+
? panel.unavailable
178+
: `preview unavailable: ${readError}`)) + '\n';
179+
}
180+
}
181+
}
182+
}
183+
if (previews === '') {
184+
return;
185+
}
186+
try {
187+
output.write(previews);
188+
} catch {
189+
return;
190+
}
191+
if (
192+
hasPreview &&
193+
allPreviewsRendered &&
194+
result.screenshotFailureMessage != null
195+
) {
196+
result.failureMessage = result.screenshotFailureMessage;
197+
}
198+
}

0 commit comments

Comments
 (0)