diff --git a/.gitignore b/.gitignore index fec5f7b67..ce2221ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,7 @@ fixture/ios/**/xcshareddata/WorkspaceSettings.xcsettings # Android fixture/android/*.hprof + +# e2e + +fixture/e2e/diff diff --git a/fixture/android/app/src/androidTest/java/com/flatlistpro/DetoxTest.java b/fixture/android/app/src/androidTest/java/com/flatlistpro/DetoxTest.java index 93afe4b7b..8d1518480 100644 --- a/fixture/android/app/src/androidTest/java/com/flatlistpro/DetoxTest.java +++ b/fixture/android/app/src/androidTest/java/com/flatlistpro/DetoxTest.java @@ -22,8 +22,8 @@ public class DetoxTest { @Test public void runDetoxTests() { DetoxConfig detoxConfig = new DetoxConfig(); - detoxConfig.idlePolicyConfig.masterTimeoutSec = 90; - detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60; + detoxConfig.idlePolicyConfig.masterTimeoutSec = 120; + detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 90; detoxConfig.rnContextLoadTimeoutSec = 180; Detox.runTests(mActivityRule, detoxConfig); diff --git a/fixture/android/app/src/main/AndroidManifest.xml b/fixture/android/app/src/main/AndroidManifest.xml index f335201c3..c4f7495a3 100644 --- a/fixture/android/app/src/main/AndroidManifest.xml +++ b/fixture/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" + android:installLocation="preferExternal" android:networkSecurityConfig="@xml/network_security_config"> { - beforeAll(async () => { - await device.launchApp(); - }); - - beforeEach(async () => { - await device.reloadReactNative(); - }); - - it("should have examples screen", async () => { - await expect(element(by.id("ExamplesFlatList"))).toBeVisible(); - }); -}); diff --git a/fixture/e2e/flashList.test.e2e.js b/fixture/e2e/flashList.test.e2e.js new file mode 100644 index 000000000..3ed2016da --- /dev/null +++ b/fixture/e2e/flashList.test.e2e.js @@ -0,0 +1,56 @@ +import * as path from "path"; +import * as fs from "fs"; +import { Platform } from "react-native"; +import { + wipeArtifactsLocation, + saveReference, + reference, +} from "../src/Detox/SnapshotLocation"; + +import { + assertSnapshotsEqual, + assertSnapshot, +} from "../src/Detox/SnapshotAsserts"; + +describe("FlashList", () => { + const flashListReferenceTestName = "Twitter with FlashList looks the same"; + + beforeAll(async () => { + await device.launchApp({ newInstance: true }); + wipeArtifactsLocation("diffs"); + }); + + beforeEach(async () => { + await device.reloadReactNative(); + }); + + it("Twitter with FlashList looks the same", async () => { + await element(by.id("Twitter Timeline")).tap(); + + const testRunScreenshotPath = await element( + by.id("FlashList") + ).takeScreenshot(flashListReferenceTestName); + + assertSnapshot(testRunScreenshotPath, flashListReferenceTestName); + }); + + it("Twitter with FlatList looks the same as with FlashList", async () => { + const testName = "Twitter with FlatList looks the same as with FlashList"; + + await element(by.id("Twitter FlatList Timeline")).tap(); + + const testRunScreenshotPath = await element( + by.id("FlatList") + ).takeScreenshot(testName); + + // Assert that FlatList reference is the same + assertSnapshot(testRunScreenshotPath, testName); + + // Assert that FlatList reference is the same as with FlashList + assertSnapshotsEqual( + reference(flashListReferenceTestName), + reference(testName), + testName + ); + }); +}); diff --git a/fixture/ios/Podfile.lock b/fixture/ios/Podfile.lock index 8c0b9c577..531c6b0d6 100644 --- a/fixture/ios/Podfile.lock +++ b/fixture/ios/Podfile.lock @@ -356,9 +356,9 @@ PODS: - React-Core - SDWebImage (~> 5.11.1) - SDWebImageWebPCoder (~> 0.8.4) - - RNFlashList (0.3.3): + - RNFlashList (0.4.0): - React-Core - - RNFlashList/Tests (0.3.3): + - RNFlashList/Tests (0.4.0): - React-Core - RNGestureHandler (2.3.2): - React-Core @@ -582,11 +582,11 @@ SPEC CHECKSUMS: Flipper-RSocket: d9d9ade67cbecf6ac10730304bf5607266dd2541 FlipperKit: d8d346844eca5d9120c17d441a2f38596e8ed2b9 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85 + glog: 5337263514dd6f09803962437687240c5dc39aa4 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc OpenSSL-Universal: 1aa4f6a6ee7256b83db99ec1ccdaa80d10f9af9b - RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685 + RCT-Folly: a21c126816d8025b547704b777a2ba552f3d9fa9 RCTRequired: 0aa6c1c27e1d65920df35ceea5341a5fe76bdb79 RCTTypeSafety: d76a59d00632891e11ed7522dba3fd1a995e573a React: ab8c09da2e7704f4b3ebad4baa6cfdfcc852dcb5 @@ -614,7 +614,7 @@ SPEC CHECKSUMS: ReactCommon: 07d0c460b9ba9af3eaf1b8f5abe7daaad28c9c4e ReactNativePerformanceListsProfiler: 3f9453c24a90c4f77db568d984c48f380968fa0d RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7 - RNFlashList: 37f03f7d76f8f2058a311a2c1f5d5abe565f0e85 + RNFlashList: eff470502cb0819757fa91cf771fb17aaf636787 RNGestureHandler: 6e757e487a4834e7280e98e9bac66d2d9c575e9c RNReanimated: 32c91e28f5780937b8efc07ddde1bab8d373fe0b RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19 diff --git a/fixture/package.json b/fixture/package.json index 6f8f51c54..9dc5bbbcf 100644 --- a/fixture/package.json +++ b/fixture/package.json @@ -26,19 +26,24 @@ "react-native-reanimated": "^2.4.1", "react-native-safe-area-context": "^3.3.2", "react-native-screens": "^3.13.1", - "recyclerlistview": "3.1.0-alpha.9", - "detox": "^19.5.7" + "recyclerlistview": "3.1.0-alpha.9" }, "devDependencies": { + "babel-jest": "^27.5.1", "babel-plugin-module-resolver": "^4.1.0", "glob": "^7.1.6", + "jest": "^27.5.1", "metro-config": "^0.69.1", "metro-react-native-babel-preset": "^0.69.1", "patch-package": "^6.4.7", "postinstall-postinstall": "^2.1.0", "typescript": "^4.6.2", - "jest": "^27.5.1", - "babel-jest": "^27.5.1" + "@types/pixelmatch": "^5.2.4", + "@types/pngjs": "^6.0.1", + "detox": "^19.5.7", + "pixelmatch": "^5.2.1", + "pngjs": "^6.0.0", + "@types/jest": "^27.4.1" }, "jest": { "preset": "react-native" diff --git a/fixture/src/App.tsx b/fixture/src/App.tsx index 73a0613d2..653b28b0f 100644 --- a/fixture/src/App.tsx +++ b/fixture/src/App.tsx @@ -55,6 +55,7 @@ const App = () => { diff --git a/fixture/src/Detox/PixelDifference.ts b/fixture/src/Detox/PixelDifference.ts new file mode 100644 index 000000000..f40f2e613 --- /dev/null +++ b/fixture/src/Detox/PixelDifference.ts @@ -0,0 +1,24 @@ +import * as fs from "fs"; + +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; + +export const pixelDifference = ( + referencePath: string, + toMatchPath: string +): PNG | null => { + const reference = PNG.sync.read(fs.readFileSync(referencePath)); + const toMatch = PNG.sync.read(fs.readFileSync(toMatchPath)); + const { width, height } = reference; + const diff = new PNG({ width, height }); + + const numDiffPixels = pixelmatch( + reference.data, + toMatch.data, + diff.data, + width, + height + ); + + return numDiffPixels > 0 ? diff : null; +}; diff --git a/fixture/src/Detox/SnapshotAsserts.ts b/fixture/src/Detox/SnapshotAsserts.ts new file mode 100644 index 000000000..fa2ef0b09 --- /dev/null +++ b/fixture/src/Detox/SnapshotAsserts.ts @@ -0,0 +1,56 @@ +import { saveDiff, reference, saveReference } from "./SnapshotLocation"; +import { pixelDifference } from "./PixelDifference"; + +export const assertSnapshot = (snapshotPath: string, testName: string) => { + const referencePath = reference(testName); + + if (referencePath) { + const diffPNG = pixelDifference(snapshotPath, referencePath); + + if (diffPNG !== null) { + const diffPath = saveDiff(diffPNG, `${testName}_diff`); + + throw Error( + `There is difference between reference screenshot and test run screenshot. + See diff: ${diffPath}` + ); + } + } else { + saveReference(snapshotPath, testName); + + throw Error( + `There is no reference screenshot present. + New reference screenshot was just created for test name "${testName}". + Please run the test again.` + ); + } +}; + +export const assertSnapshotsEqual = ( + firstPath: string | null, + secondPath: string | null, + testName: string +) => { + if (!firstPath) { + throw new Error( + `Invalid path: ${firstPath}. Please make sure that you have a screenshot before running this assertion.` + ); + } + + if (!secondPath) { + throw new Error( + `Invalid path: ${secondPath}. Please make sure that you have a screenshot before running this assertion.` + ); + } + + const diffPNG = pixelDifference(firstPath, secondPath); + + if (diffPNG !== null) { + const diffPath = saveDiff(diffPNG, `${testName}_diff.png`); + + throw Error( + `There is difference between reference screenshot and test run screenshot. + See diff: ${diffPath}` + ); + } +}; diff --git a/fixture/src/Detox/SnapshotLocation.ts b/fixture/src/Detox/SnapshotLocation.ts new file mode 100644 index 000000000..561b5de97 --- /dev/null +++ b/fixture/src/Detox/SnapshotLocation.ts @@ -0,0 +1,59 @@ +import * as path from "path"; +import * as fs from "fs"; + +import detox from "detox"; +import { PNG } from "pngjs"; + +const ROOT_PATH = path.resolve(__dirname, ".."); + +const artifactsLocation = (testCaseName: string): string => { + const platform = detox.device.getPlatform(); + const location = path.resolve( + ROOT_PATH, + `../e2e/artifacts/${platform}`, + testCaseName + ); + + return location; +}; + +export const ensureArtifactsLocation = (name: string): string => { + const location = artifactsLocation(name); + + if (!fs.existsSync(location)) { + fs.mkdirSync(location, { recursive: true }); + } + + return location; +}; + +export const wipeArtifactsLocation = (name: string) => { + const location = artifactsLocation(name); + + if (fs.existsSync(location)) { + fs.rmdirSync(location, { recursive: true }); + } +}; + +export const saveDiff = (diff: PNG, testName: string): string => { + const diffsLocation = ensureArtifactsLocation("diff"); + const diffPath = path.resolve(diffsLocation, testName); + fs.writeFileSync(diffPath, PNG.sync.write(diff)); + + return diffPath; +}; + +export const saveReference = (referencePath: string, testName: string) => { + const testArtifactsLocation = ensureArtifactsLocation(testName); + const referenceName = path.resolve(testArtifactsLocation, `${testName}.png`); + + fs.renameSync(referencePath, referenceName); + + console.log(`Reference screenshot for test name "${testName}" was created`); +}; + +export const reference = (testName: string): string | null => { + const testArtifactsLocation = ensureArtifactsLocation(testName); + const referenceName = path.resolve(testArtifactsLocation, `${testName}.png`); + return fs.existsSync(referenceName) ? referenceName : null; +}; diff --git a/fixture/src/ExamplesScreen.tsx b/fixture/src/ExamplesScreen.tsx index c9d8a3870..516ffacba 100644 --- a/fixture/src/ExamplesScreen.tsx +++ b/fixture/src/ExamplesScreen.tsx @@ -38,6 +38,7 @@ export const ExamplesScreen = () => { onPress={() => { navigate(item.destination); }} + testID={item.title} > {item.title} diff --git a/fixture/src/Twitter.tsx b/fixture/src/Twitter.tsx index f7da628a9..868881641 100644 --- a/fixture/src/Twitter.tsx +++ b/fixture/src/Twitter.tsx @@ -10,6 +10,7 @@ const Twitter = () => { return ( { return item.id; }} diff --git a/fixture/src/TwitterFlatList.tsx b/fixture/src/TwitterFlatList.tsx index 0511192fd..c1243792d 100644 --- a/fixture/src/TwitterFlatList.tsx +++ b/fixture/src/TwitterFlatList.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { FlatList } from "react-native"; +import { FlatList, View } from "react-native"; import { FlatListPerformanceView } from "@shopify/react-native-performance-lists-profiler"; import { tweets } from "./data/tweets"; @@ -9,19 +9,21 @@ import { Header, Footer, Divider } from "./Twitter"; const TwitterFlatList = () => { return ( - { - return item.id; - }} - renderItem={({ item }) => { - return ; - }} - ListHeaderComponent={Header} - ListHeaderComponentStyle={{ backgroundColor: "#ccc" }} - ListFooterComponent={Footer} - ItemSeparatorComponent={Divider} - data={tweets} - /> + + { + return item.id; + }} + renderItem={({ item }) => { + return ; + }} + ListHeaderComponent={Header} + ListHeaderComponentStyle={{ backgroundColor: "#ccc" }} + ListFooterComponent={Footer} + ItemSeparatorComponent={Divider} + data={tweets} + /> + ); }; diff --git a/fixture/tsconfig.json b/fixture/tsconfig.json index 1aee7a560..1c33011d6 100644 --- a/fixture/tsconfig.json +++ b/fixture/tsconfig.json @@ -1,14 +1,14 @@ { - "extends": "../shared/tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", - "outDir": "./dist", - "rootDir": "./src" - }, - "references": [ - { - "path": "../" - } - ] - } + "extends": "../shared/tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../" + } + ] +} diff --git a/fixture/yarn.lock b/fixture/yarn.lock index 6a101b8e0..9f9650bb5 100644 --- a/fixture/yarn.lock +++ b/fixture/yarn.lock @@ -1319,10 +1319,8 @@ warn-once "^0.1.0" "@shopify/flash-list@link:..": - version "0.4.0" - dependencies: - invariant "^2.2.4" - recyclerlistview "3.1.0-alpha.9" + version "0.0.0" + uid "" "@shopify/react-native-performance-lists-profiler@^0.0.10": version "0.0.10" @@ -1434,11 +1432,33 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@^27.4.1": + version "27.4.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.1.tgz#185cbe2926eaaf9662d340cc02e548ce9e11ab6d" + integrity sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw== + dependencies: + jest-matcher-utils "^27.0.0" + pretty-format "^27.0.0" + "@types/node@*": version "17.0.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.9.tgz#0b7f161afb5b1cc12518d29b2cdc7175d5490628" integrity sha512-5dNBXu/FOER+EXnyah7rn8xlNrfMOQb/qXnw4NQgLkCygKBKhdmF/CA5oXVOKZLBEahw8s2WP9LxIcN/oDDRgQ== +"@types/pixelmatch@^5.2.4": + version "5.2.4" + resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6" + integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ== + dependencies: + "@types/node" "*" + +"@types/pngjs@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072" + integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg== + dependencies: + "@types/node" "*" + "@types/prettier@^2.1.5": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.4.tgz#5d9b63132df54d8909fce1c3f8ca260fdd693e17" @@ -3709,7 +3729,7 @@ jest-leak-detector@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-matcher-utils@^27.5.1: +jest-matcher-utils@^27.0.0, jest-matcher-utils@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== @@ -5364,6 +5384,13 @@ pirates@^4.0.4, pirates@^4.0.5: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== +pixelmatch@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.2.1.tgz#9e4e4f4aa59648208a31310306a5bed5522b0d65" + integrity sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ== + dependencies: + pngjs "^4.0.1" + pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" @@ -5393,6 +5420,16 @@ plist@^3.0.2, plist@^3.0.4: base64-js "^1.5.1" xmlbuilder "^9.0.7" +pngjs@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe" + integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg== + +pngjs@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821" + integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg== + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -5418,7 +5455,7 @@ pretty-format@^26.5.2, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" -pretty-format@^27.5.1: +pretty-format@^27.0.0, pretty-format@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==