import callsites from "callsites"; import chalk from "chalk"; import { existsSync } from "fs"; import { toMatchImageSnapshot } from "jest-image-snapshot"; import { dirname, join, sep } from "path"; import { debugLogger } from "../logger"; import { fetch } from "../network/fetch"; import { Viewport } from "../screenshot-renderer/api"; import { getScreenshotPrefix, SCREENSHOT_MODE, SCREENSHOT_SERVER_URL, } from "../screenshot-server/config"; import { ReactComponentServer } from "./ReactComponentServer"; const logDebug = debugLogger("ReactScreenshotTest"); /** * ReactScreenshotTest is a builder for screenshot tests. * * Example usage: * ``` * describe("screenshots", () => { * ReactScreenshotTest.create("MyComponent") * .viewports(VIEWPORTS) * .shoot("with title", ) * .shoot("without title", ) * .run(); * }); * ``` */ export class ReactScreenshotTest { private readonly _viewports: { [name: string]: Viewport; } = {}; private readonly _shots: { [name: string]: React.ReactNode; } = {}; private readonly _remoteStylesheetUrls: string[] = []; private readonly _staticPaths: Record = {}; private ran = false; /** * Creates a screenshot test. */ static create(componentName: string) { return new this(componentName); } private constructor(private readonly componentName: string) { setImmediate(() => { if (!this.ran) { throw new Error("Please call .run()"); } }); } /** * Adds a set of viewports to the screenshot test. */ viewports(viewports: { [name: string]: Viewport }) { for (const [name, viewport] of Object.entries(viewports)) { this.viewport(name, viewport); } return this; } /** * Adds a single viewport to the screenshot test. */ viewport(viewportName: string, viewport: Viewport) { if (this.ran) { throw new Error("Cannot add a viewport after running."); } if (this._viewports[viewportName]) { throw new Error(`Viewport "${viewportName}" is declared more than once`); } this._viewports[viewportName] = viewport; return this; } /** * Adds a specific shot of a component to the screenshot test. */ shoot(shotName: string, component: React.ReactNode) { if (this.ran) { throw new Error("Cannot add a shot after running."); } if (this._shots[shotName]) { throw new Error(`Shot "${shotName}" is declared more than once`); } this._shots[shotName] = component; return this; } remoteStylesheet(stylesheetUrl: string) { this._remoteStylesheetUrls.push(stylesheetUrl); return this; } static(mappedPath: string, dirOrFilePath: string) { if (!mappedPath.startsWith("/")) { throw new Error("Directory mapping path must start with /"); } if (!existsSync(dirOrFilePath)) { throw new Error( `Could not find path "${dirOrFilePath}". Consider using path.resolve() to get an absolute path.` ); } if (this._staticPaths[mappedPath]) { throw new Error("Cannot map multiple directories to the same path"); } this._staticPaths[mappedPath] = dirOrFilePath; return this; } /** * Runs the actual test (delegating to Jest). */ run() { if (this.ran) { throw new Error("Cannot run more than once."); } this.ran = true; if (Object.keys(this._viewports).length === 0) { throw new Error("Please define viewports with .viewport()"); } if (Object.keys(this._shots).length === 0) { throw new Error("Please define shots with .shoot()"); } const componentServer = new ReactComponentServer(this._staticPaths); expect.extend({ toMatchImageSnapshot }); beforeAll(async () => { await componentServer.start(); }); afterAll(async () => { await componentServer.stop(); }); const testFilename = callsites()[1].getFileName()!; const snapshotsDir = dirname(testFilename); const prefix = getScreenshotPrefix(); // jest-image-snapshot doesn't support a snapshot identifier such as // "abc/def". Instead, we need some logic to look for a directory // separator (using `sep`) and set the subdirectory to "abc", only using // "def" as the identifier prefix. let subdirectory = ""; let filenamePrefix = ""; if (prefix.indexOf(sep) > -1) { [subdirectory, filenamePrefix] = prefix.split(sep, 2); } else { filenamePrefix = prefix; } describe(this.componentName, () => { for (const [viewportName, viewport] of Object.entries(this._viewports)) { describe(viewportName, () => { for (const [shotName, shot] of Object.entries(this._shots)) { it(shotName, async () => { const name = `${this.componentName} - ${viewportName} - ${shotName}`; logDebug( `Requesting component server to generate screenshot: ${name}` ); const screenshot = await componentServer.serve( { name, reactNode: shot, remoteStylesheetUrls: this._remoteStylesheetUrls, }, async (port, path) => { // docker.interval is only available on window and mac const url = SCREENSHOT_MODE === "docker" && process.platform !== "linux" ? `http://host.docker.internal:${port}${path}` : `http://localhost:${port}${path}`; return this.render(name, url, viewport); } ); logDebug(`Screenshot generated.`); if (screenshot) { logDebug(`Comparing screenshot.`); expect(screenshot).toMatchImageSnapshot({ customSnapshotsDir: join( snapshotsDir, "__screenshots__", this.componentName, subdirectory ), customSnapshotIdentifier: `${filenamePrefix}${viewportName} - ${shotName}`, }); logDebug(`Screenshot compared.`); } else { logDebug(`Skipping screenshot matching.`); } }); } }); } }); } private async render(name: string, url: string, viewport: Viewport) { let response: { status: number; body: Buffer; }; try { logDebug( `Initiating request to screenshot server at ${SCREENSHOT_SERVER_URL}.` ); response = await fetch(`${SCREENSHOT_SERVER_URL}/render`, "POST", { name, url, viewport, }); } catch (e) { // eslint-disable-next-line no-console console.error( chalk.red( `Unable to reach screenshot server. Please make sure that your Jest configuration contains the following: { "globalSetup": "react-screenshot-test/global-setup", "globalTeardown": "react-screenshot-test/global-teardown" } ` ) ); throw e; } logDebug(`Response received with status code ${response.status}.`); if (response.status === 204) { return null; } if (response.status !== 200) { // eslint-disable-next-line no-console console.error( chalk.red( `Screenshot server failed to render (status ${response.status}). Error: ${response.body.toString("utf8")} ` ) ); throw new Error( `Received response ${response.status} from screenshot server.` ); } return response.body; } }