Share
Embed
Keeping your Pixels perfectย ๐ธ
Unit tests allow you refactor your code with confidence and -- if architected correctly -- run blazingly fast!
But how do you ensure that your UI looks as expected? Espresso tests are hard to set up, prone to flakiness and require APKs, emulators, dexing, ADB...ugh. Snapshot tests get you closer but also rely on instrumentation tests.
Come find out how we're improving the UI testing loop on Cash App!
Video: https://www.droidcon.com/media-detail?video=362742507
John Rodriguez
August 27, 2019
Transcript
145 slides
-
<LinearLayout android:orientation="vertical" android:gravity="center" android:background="@color/launchBackground" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:src="@drawable/camera"
/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="@color/cameraBody" android:text="paparazzi" android:textSize="70sp" android:textFontWeight="100" /> -
<LinearLayout android:orientation="vertical" android:gravity="center" android:background="@color/launchBackground" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:src="@drawable/camera"
/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="@color/cameraBody" android:text="paparazzi" android:textSize="70sp" android:textFontWeight="100" /> -
class LaunchViewTest { @get:Rule var paparazzi = Paparazzi("app.cash.paparazzi.sample") @Test fun
testViews() { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch) paparazzi.snapshot(launch, "launch") } } -
sample/ โโโ build.gradle โโโ src โโโ main โ โโโ AndroidManifest.xml
โ โโโ res โ โโโ drawable โ โ โโโ camera.png โ โโโ layout โ โ โโโ launch.xml โ โโโ values โ โโโ colors.xml โโโ test โโโ java โโโ app โโโ cash โโโ paparazzi โโโ sample โโโ LaunchViewTest.kt -
sample/ โโโ build โโโ reports โโโ paparazzi โโโ images โ
โโโ 215bfbc973787f8c7e63650970fefc19fdd3399d.png โโโ index.html โโโ index.js โโโ paparazzi.js โโโ runs โโโ 20190826232155_06ba49.js -
sample/ โโโ build โโโ reports โโโ paparazzi โโโ images โ
โโโ 215bfbc973787f8c7e63650970fefc19fdd3399d.png โโโ index.html โโโ index.js โโโ paparazzi.js โโโ runs โโโ 20190826232155_06ba49.js -
public Drawable getDrawable(@DrawableRes int id, Theme t) { return getDrawableForDensity(id,
0, t); } android.content.res.Resources (android-28) -
public Drawable getDrawable_Original(@DrawableRes int id, Theme t) { return getDrawableForDensity(id,
0, t); } @LayoutlibDelegate public Drawable getDrawable(@DrawableRes int id, Theme t) { return Resources_Delegate.getDrawable(id, 0, t); } android.content.res.Resources (layoutlib) -
@LayoutlibDelegate static Drawable getDrawable(Resources resources, int id, Theme theme) {
Pair<String, ResourceValue> value = getResourceValue(resources, id); if (value != null) { return ResourceHelper.getDrawable(value.second, getContext(resources), theme); } throwException(resources, id); } android.content.res.Resources_Delegate (layoutlib) -
$ find $HOME/.gradle/caches/ -name layoutlib-3.4.0.jar | xargs jar tf |
grep _Delegate android/content/res/AssetManager_Delegate.class android/content/res/Resources_Delegate.class android/content/res/Resources_Theme_Delegate.class android/content/res/TypedArray_Delegate.class android/graphics/BitmapFactory_Delegate.class android/graphics/Bitmap_Delegate.class android/graphics/Canvas_Delegate.class android/graphics/Color_Delegate.class android/graphics/FontFamily_Delegate.class android/graphics/Matrix_Delegate.class android/graphics/Paint_Delegate.class android/graphics/Typeface_Delegate.class android/graphics/drawable/AnimatedVectorDrawable_Delegate.class android/graphics/drawable/VectorDrawable_Delegate.class android/os/Binder_Delegate.class android/os/Handler_Delegate.class android/os/SystemClock_Delegate.class android/os/SystemProperties_Delegate.class -
android/graphics/drawable/AnimatedVectorDrawable_Delegate.class android/graphics/drawable/VectorDrawable_Delegate.class android/os/Binder_Delegate.class android/os/Handler_Delegate.class android/os/SystemClock_Delegate.class android/os/SystemProperties_Delegate.class android/text/format/DateFormat_Delegate.class android/util/Log_Delegate.class android/view/Choreographer_Delegate.class android/view/Display_Delegate.class
android/view/LayoutInflater_Delegate.class android/view/MenuInflater_Delegate.class android/view/ViewGroup_Delegate.class android/view/ViewRootImpl_Delegate.class android/view/View_Delegate.class android/view/WindowManagerGlobal_Delegate.class com/android/tools/layoutlib/java/LinkedHashMap_Delegate.class com/android/tools/layoutlib/java/System_Delegate.class dalvik/system/VMRuntime_Delegate.class libcore/icu/ICU_Delegate.class libcore/io/MemoryMappedFile_Delegate.class libcore/util/NativeAllocationRegistry_Delegate.class -
public final class Bridge extends com.android.ide.common.rendering.api.Bridge { public boolean init(
Map<String, String> platformProperties, File fontLocation, String icuDataPath, Map<String, Map<String, Integer>> enumValueMap, LayoutLog log ) {โฆ} public RenderSession createSession(SessionParams params) {โฆ} } layoutlib -
public RenderSession createSession(SessionParams params) { RenderSessionImpl scene = new RenderSessionImpl(params);
Result lastResult; try { prepareThread(); lastResult = scene.init(params.getTimeout()); if (lastResult.isSuccess()) { lastResult = scene.inflate(); boolean doNotRenderOnCreate = โฆ if (lastResult.isSuccess() && !doNotRenderOnCreate) { lastResult = scene.render(true); } } } finally { scene.release(); cleanupThread(); } return new BridgeRenderSession(scene, lastResult); } -
public RenderSession createSession(SessionParams params) { RenderSessionImpl scene = new RenderSessionImpl(params);
Result lastResult; try { prepareThread(); lastResult = scene.init(params.getTimeout()); if (lastResult.isSuccess()) { lastResult = scene.inflate(); boolean doNotRenderOnCreate = โฆ if (lastResult.isSuccess() && !doNotRenderOnCreate) { lastResult = scene.render(true); } } } finally { scene.release(); cleanupThread(); } return new BridgeRenderSession(scene, lastResult); } -
public Result init(long timeout) { Result result = super.init(timeout); if
(!result.isSuccess()) { return result; } else { SessionParams params = (SessionParams)this.getParams(); BridgeContext context = this.getContext(); mInflater = BridgeInflater(context, params.layoutlibCallback); context.setBridgeInflater(this.mInflater); ILayoutPullParser layoutParser = params.getLayoutDescription(); mBlockParser = BridgeXmlBlockParser(layoutParser, context, โฆ); return Status.SUCCESS.createResult(); } } -
public Result init(long timeout) { Result result = super.init(timeout); if
(!result.isSuccess()) { return result; } else { SessionParams params = (SessionParams)this.getParams(); BridgeContext context = this.getContext(); mInflater = BridgeInflater(context, params.layoutlibCallback); context.setBridgeInflater(this.mInflater); ILayoutPullParser layoutParser = params.getLayoutDescription(); mBlockParser = BridgeXmlBlockParser(layoutParser, context, โฆ); return Status.SUCCESS.createResult(); } } -
public abstract class RenderAction<T extends RenderParams> { public Result init(long
timeout) { HardwareConfig hardwareConfig = mParams.getHardwareConfig(); DisplayMetrics metrics = new DisplayMetrics(); metrics.densityDpi = metrics.noncompatDensityDpi = โฆ metrics.density = metrics.noncompatDensity = โฆ RenderResources resources = this.mParams.getResources(); this.mContext = new BridgeContext(this.mParams); this.setUp(); return Status.SUCCESS.createResult(); } } -
public abstract class RenderAction<T extends RenderParams> { public Result init(long
timeout) { HardwareConfig hardwareConfig = mParams.getHardwareConfig(); DisplayMetrics metrics = new DisplayMetrics(); metrics.densityDpi = metrics.noncompatDensityDpi = โฆ metrics.density = metrics.noncompatDensity = โฆ RenderResources resources = this.mParams.getResources(); this.mContext = new BridgeContext(this.mParams); this.setUp(); return Status.SUCCESS.createResult(); } } -
public abstract class RenderAction<T extends RenderParams> { public Result init(long
timeout) { HardwareConfig hardwareConfig = mParams.getHardwareConfig(); DisplayMetrics metrics = new DisplayMetrics(); metrics.densityDpi = metrics.noncompatDensityDpi = โฆ metrics.density = metrics.noncompatDensity = โฆ RenderResources resources = this.mParams.getResources(); this.mContext = new BridgeContext(this.mParams); this.setUp(); return Status.SUCCESS.createResult(); } } -
public Result init(long timeout) { Result result = super.init(timeout); if
(!result.isSuccess()) { return result; } else { SessionParams params = (SessionParams)this.getParams(); BridgeContext context = this.getContext(); mInflater = BridgeInflater(context, params.layoutlibCallback); context.setBridgeInflater(this.mInflater); ILayoutPullParser layoutParser = params.getLayoutDescription(); mBlockParser = BridgeXmlBlockParser(layoutParser, context, โฆ); return Status.SUCCESS.createResult(); } } -
public Result init(long timeout) { Result result = super.init(timeout); if
(!result.isSuccess()) { return result; } else { SessionParams params = (SessionParams)this.getParams(); BridgeContext context = this.getContext(); mInflater = BridgeInflater(context, params.layoutlibCallback); context.setBridgeInflater(this.mInflater); ILayoutPullParser layoutParser = params.getLayoutDescription(); mBlockParser = BridgeXmlBlockParser(layoutParser, context, โฆ); return Status.SUCCESS.createResult(); } } -
$ unzip cash-master-internal-debug-6765.apk Archive: cash-master-internal-debug-6765.apk inflating: AndroidManifest.xml inflating: LICENSE_OFL inflating:
LICENSE_UNICODE inflating: META-INF/CERT.RSA inflating: META-INF/CERT.SF inflating: META-INF/MANIFEST.MF extracting: assets/appmessage/activity-card-promo-fallback.png extracting: assets/appmessage/activity-card-promo-placeholder.png extracting: assets/appmessage/activity-card-promo.mp4 extracting: assets/appmessage/bitcoin-activity-fallback.png extracting: assets/appmessage/bitcoin-activity-placeholder.png extracting: assets/appmessage/bitcoin-activity.mp4 extracting: assets/appmessage/bitcoin-full-screen.mp4 extracting: assets/appmessage/cage-banner.jpeg extracting: assets/appmessage/cage-ceo.jpg extracting: assets/appmessage/cage-face-off.jpg โฆ -
$ cd res/layout $ ls -1 activity_app_message_video_view.xml activity_check_payment_status_dialog.xml activity_contact_empty_view.xml activity_contact_empty_view_header_view.xml
activity_contact_header.xml activity_contact_item.xml activity_header_view.xml activity_header_view_tab.xml activity_invite.xml activity_invite_item.xml activity_item.xml activity_payment_view.xml activity_receipt_detail_button.xml activity_receipt_detail_item.xml activity_receipt_details_sheet.xml โฆ -
$ less blockers_selection_detail_row.xml ^C^@^H^@^\^D^@^@^A^@^\^@p^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@ ^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^@^@f^@^@^@v^@^ @^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <DF>^@^@^@^L^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayo ut_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclass^@99com.squareup.cash.u
i.blockers.SelectionView$DetailRowView^@**http://schemas.android.com/ apk/res/ android^@^D^Dview^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^ @^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^A<F5>^@^A^A<81>^A^A^A^@^A^ P^@^X^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^ B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@^T^@^F^@^@^@^F^@^@^ @^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF>< FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^F^@^@^@<FF>B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF> -
$ less blockers_selection_detail_row.xml ^C^@^H^@^\^D^@^@^A^@^\^@p^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@ ^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^@^@f^@^@^@v^@^ @^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <DF>^@^@^@^L^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayo ut_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclass^@99com.squareup.cash.u
i.blockers.SelectionView$DetailRowView^@**http://schemas.android.com/ apk/res/ android^@^D^Dview^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^ @^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^A<F5>^@^A^A<81>^A^A^A^@^A^ P^@^X^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^ B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@^T^@^F^@^@^@^F^@^@^ @^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF>< FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^F^@^@^@<FF>B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF> -
$ less blockers_selection_detail_row.xml ^C^@^H^@^\^D^@^@^A^@^\^@p^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@ ^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^@^@f^@^@^@v^@^ @^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <DF>^@^@^@^L^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayo ut_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclass^@99com.squareup.cash.u
i.blockers.SelectionView$DetailRowView^@**http://schemas.android.com/ apk/res/ android^@^D^Dview^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^ @^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^A<F5>^@^A^A<81>^A^A^A^@^A^ P^@^X^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^ B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@^T^@^F^@^@^@^F^@^@^ @^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF>< FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^F^@^@^@<FF>B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF> -
$ less blockers_selection_detail_row.xml ^C^@^H^@^\^D^@^@^A^@^\^@p^A^@^@^P^@^@^@^@^@^@^@^@^A^@^@\^@^@^@^@^@^@^@ ^@^@^@^@^Q^@^@^@^]^@^@^@'^@^@^@5^@^@^@:^@^@^@G^@^@^@W^@^@^@f^@^@^@v^@^ @^@<86>^@^@^@<91>^@^@^@<9B>^@^@^@<A3>^@^@^@ <DF>^@^@^@^L^A^@^@^N^NtextAppearance^@ textColor^@^G^Ggravity^@^K^Korientation^@^B^Bid^@ paddingTop^@^MpaddingBottom^@^L^Llayout_width^@^Mlayout_height^@^Mlayo ut_weight^@^H^HTextView^@^G^Gandroid^@^E^Eclass^@99com.squareup.cash.u
i.blockers.SelectionView$DetailRowView^@**http://schemas.android.com/ apk/res/ android^@^D^Dview^@^@<80>^A^H^@0^@^@^@4^@^A^A<98>^@^A^A<AF>^@^A^A<C4>^ @^A^A<D0>^@^A^A<D7>^@^A^A<D9>^@^A^A<F4>^@^A^A<F5>^@^A^A<81>^A^A^A^@^A^ P^@^X^@^@^@^B^@^@^@<FF><FF><FF><FF>^K^@^@^@^N^@^@^@^B^A^P^@<9C>^@^@^@^ B^@^@^@<FF><FF><FF><FF><FF><FF><FF><FF>^O^@^@^@^T^@^T^@^F^@^@^@^F^@^@^ @^N^@^@^@^C^@^@^@<FF><FF><FF><FF>^H^@^@^P^@^@^@^@^N^@^@^@^E^@^@^@<FF>< FF><FF><FF>^H^@^@^E^A^D^@^@^N^@^@^@^F^@^@^@<FF>B^F^? ^N^@^@^@^B^@^@^@<FF><FF><FF><FF>^H^@^@^Q^C^@<80>^@^N^@^@^@^D^@^@^@<FF> -
public Result init(long timeout) { Result result = super.init(timeout); if
(!result.isSuccess()) { return result; } else { SessionParams params = (SessionParams)this.getParams(); BridgeContext context = this.getContext(); mInflater = BridgeInflater(context, params.layoutlibCallback); context.setBridgeInflater(this.mInflater); ILayoutPullParser layoutParser = params.getLayoutDescription(); mBlockParser = BridgeXmlBlockParser(layoutParser, context, โฆ); return Status.SUCCESS.createResult(); } } -
public RenderSession createSession(SessionParams params) { RenderSessionImpl scene = new RenderSessionImpl(params);
Result lastResult; try { prepareThread(); lastResult = scene.init(params.getTimeout()); if (lastResult.isSuccess()) { lastResult = scene.inflate(); boolean doNotRenderOnCreate = โฆ if (lastResult.isSuccess() && !doNotRenderOnCreate) { lastResult = scene.render(true); } } } finally { scene.release(); cleanupThread(); } return new BridgeRenderSession(scene, lastResult); } -
public RenderSession createSession(SessionParams params) { RenderSessionImpl scene = new RenderSessionImpl(params);
Result lastResult; try { prepareThread(); lastResult = scene.init(params.getTimeout()); if (lastResult.isSuccess()) { lastResult = scene.inflate(); boolean doNotRenderOnCreate = โฆ if (lastResult.isSuccess() && !doNotRenderOnCreate) { lastResult = scene.render(true); } } } finally { scene.release(); cleanupThread(); } return new BridgeRenderSession(scene, lastResult); } -
public RenderSession createSession(SessionParams params) { RenderSessionImpl scene = new RenderSessionImpl(params);
Result lastResult; try { prepareThread(); lastResult = scene.init(params.getTimeout()); if (lastResult.isSuccess()) { lastResult = scene.inflate(); boolean doNotRenderOnCreate = โฆ if (lastResult.isSuccess() && !doNotRenderOnCreate) { lastResult = scene.render(true); } } } finally { scene.release(); cleanupThread(); } return new BridgeRenderSession(scene, lastResult); } -
โข layoutlib_create -> android1.jar android2.jar โฆ โข *_Delegate โข Bridge
โข RenderSession Recap: layoutlib -
$ ./gradlew paparazzi:dependencies --configuration compileClasspath > Task :paparazzi:dependencies ------------------------------------------------------------ Project
:paparazzi โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ compileClasspath - Compile classpath for compilation 'main'. +--- app.cash.paparazzi:layoutlib:3.4.0 +--- com.android.tools:sdk-common:26.4.2 | +--- com.android.tools:sdklib:26.4.2 | | +--- com.android.tools.layoutlib:layoutlib-api:26.4.2 (*) | | +--- com.android.tools:common:26.4.2 (*) | | +--- net.sf.kxml:kxml2:2.3.0 โฆ โฆ โฆ -
$ ./gradlew paparazzi:dependencies --configuration compileClasspath > Task :paparazzi:dependencies ------------------------------------------------------------ Project
:paparazzi โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ compileClasspath - Compile classpath for compilation 'main'. +--- app.cash.paparazzi:layoutlib:3.4.0 +--- com.android.tools:sdk-common:26.4.2 | +--- com.android.tools:sdklib:26.4.2 | | +--- com.android.tools.layoutlib:layoutlib-api:26.4.2 (*) | | +--- com.android.tools:common:26.4.2 (*) | | +--- net.sf.kxml:kxml2:2.3.0 โฆ โฆ โฆ -
abstract class LayoutlibCallback { abstract Object loadView( String name, Class[]
ctorSignature, Object[] ctorArgs); abstract ResourceReference resolveResourceId(int id); โฆ } com.android.tools.layoutlib:layoutlib-api -
/** Common interface for all Android resource repositories. */ public
interface ResourceRepository { List<ResourceItem> getResources( ResourceNamespace namespace, ResourceType resourceType, String resourceName); โฆ } com.android.tools:sdk-common -
public class ResourceResolver { ResourceResolver create(Map<ResourceNamespace, Map<โฆ>> res, โฆ) {
โฆ ResourceResolver resolver = new ResourceResolver(res, theme); resolver.preProcessStyles(); return resolver; } ResourceValue findResource(ResourceReference ref) { ResourceValueMap resourceValueMap = getResourceValueMap(ref.getNamespace(), ref.getResourceType()); if (resourceValueMap != null) { return resourceValueMap.get(reference.getName()); } return null; } } com.android.tools:sdk-common -
class Paparazzi( private val packageName: String, private val environment: Environment,
private val snapshotHandler: SnapshotHandler ) : TestRule -
class Paparazzi( private val packageName: String, private val environment: Environment,
private val snapshotHandler: SnapshotHandler ) : TestRule data class Environment( val platformDir: String, val testDir: String ) { val testResDir = "$testDir/app/build/intermediates/classes/โฆ" val assetsDir = "$testDir/src/main/assets" val resDir = "$testDir/src/main/res" } -
class Paparazzi( private val packageName: String, private val environment: Environment
= detectEnvironment(), private val snapshotHandler: SnapshotHandler ) : TestRule data class Environment( val platformDir: String, val testDir: String ) {โฆ} fun detectEnvironment(): Environment { val userDir = System.getProperty("user.dir") val userHome = System.getProperty("user.home") val androidHome = System.getenv("ANDROID_HOME") ?: "$userHome/Library/Android/sdk" val platformDir = "$androidHome/platforms/android-โฆ/" return Environment(platformDir, userDir) } -
class Paparazzi( private val packageName: String, private val environment: Environment
= detectEnvironment(), private val snapshotHandler: SnapshotHandler ) : TestRule {โฆ} -
class Paparazzi( private val packageName: String, private val environment: Environment
= detectEnvironment(), private val snapshotHandler: SnapshotHandler = HtmlReportWriter() ) : TestRule {โฆ} -
class Paparazzi( private val packageName: String, private val environment: Environment,
private val snapshotHandler: SnapshotHandler ) : TestRule { fun prepare(description: Description) {โฆ} } -
$ find sample/ -name "R*" sample/build/intermediates/โฆ/debug/generateDebugRFile/R.jar sample/build/intermediates/symbols/debug/R.txt $ JAR=sample/build/intermediates/โฆ/debug/generateDebugRFile/R.jar $
javap -classpath $JAR $(jar -tf $JAR | grep "class$" | sed s/ \.class$//) public final class app.cash.paparazzi.sample.R$color { public static int cameraBody; public static int launchBackground; } public final class app.cash.paparazzi.sample.R$drawable { public static int camera; } -
fun prepare(description: Description) { val layoutlibCallback = PaparazziCallback(logger, packageName) layoutlibCallback.initResources()
renderer = Renderer(environment, layoutlibCallback, logger) val sessionParamsBuilder = renderer.prepare() } -
fun prepare(description: Description) { val layoutlibCallback = PaparazziCallback(logger, packageName) layoutlibCallback.initResources()
renderer = Renderer(environment, layoutlibCallback, logger) val sessionParamsBuilder = renderer.prepare() val root = "<FrameLayoutโฆ/>" val sessionParams = sessionParamsBuilder .copy( layoutPullParser = LayoutPullParser.createFromString(root), renderingMode = SessionParams.RenderingMode.V_SCROLL ) .withTheme("Theme.Material.Light.NoActionBar.Fullscreen", false) .build() } -
fun prepare(description: Description) { val layoutlibCallback = PaparazziCallback(logger, packageName) layoutlibCallback.initResources()
renderer = Renderer(environment, layoutlibCallback, logger) val sessionParamsBuilder = renderer.prepare() val root = "<FrameLayoutโฆ/>" val sessionParams = sessionParamsBuilder .copy( layoutPullParser = LayoutPullParser.createFromString(root), renderingMode = SessionParams.RenderingMode.V_SCROLL ) .withTheme("Theme.Material.Light.NoActionBar.Fullscreen", false) .build() renderSession = RenderSessionImpl(sessionParams) prepareThread() renderSession.init(sessionParams.timeout) bridgeRenderSession = createBridgeSession(renderSession, renderSession.inflate()) } -
class LaunchViewTest { @get:Rule var paparazzi = Paparazzi("app.cash.paparazzi.sample") @Test fun
testViews() { val launch = paparazzi.inflate<LinearLayout>(R.layout.launch) paparazzi.snapshot(launch, "launch") } } -
fun snapshot(view: View, name: String) { val snapshot = Snapshot(name,
testName, Date()) val frameHandler = snapshotHandler.newFrameHandler(snapshot) frameHandler.use { val viewGroup = bridgeRenderSession.rootViews[0].viewObject viewGroup.addView(view) try { withTime(0) { renderSession.render(true) frameHandler.handle(scaleImage(bridgeRenderSession.image)) } } finally { viewGroup.removeView(view) } } } -
sample/ โโโ build.gradle โโโ src โโโ main โ โโโ AndroidManifest.xml
โ โโโ res โ โโโ drawable โ โ โโโ camera.png โ โโโ layout โ โ โโโ launch.xml โ โโโ values โ โโโ colors.xml โโโ test โโโ java โโโ app โโโ cash โโโ paparazzi โโโ sample โโโ LaunchViewTest.kt -
โโโ resources โ โโโ build.gradle โ โโโ src โ โโโ
main โ โโโ AndroidManifest.xml โ โโโ res โ โโโ drawable โ โ โโโ camera.png โ โโโ values โ โโโ colors.xml โโโ sample โโโ โฆ -
<LinearLayout android:orientation="vertical" android:gravity="center" android:background="@color/launchBackground" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:src="@drawable/camera"
/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="@color/cameraBody" android:text="paparazzi" android:textSize="70sp" android:textFontWeight="100" /> -
<LinearLayout android:orientation="vertical" android:gravity="center" android:background="@color/launchBackground" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:src="@drawable/camera"
/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="@color/cameraBody" android:text="paparazzi" android:textSize="70sp" android:textFontWeight="100" /> -
<LinearLayout android:orientation="vertical" android:gravity="center" android:background="@color/launchBackground" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="200dp" android:layout_height="200dp" android:src="@drawable/camera"
/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="@color/cameraBody" android:text="paparazzi" android:textSize="70sp" android:textFontWeight="100" /> -
class Paparazzi( private val packageName: String, private val environment: Environment,
private val snapshotHandler: SnapshotHandler ) : TestRule data class Environment( val platformDir: String, val testDir: String ) { val testResDir = "$testDir/app/build/intermediates/classes/โฆ" val assetsDir = "$testDir/src/main/assets" val resDir = "$testDir/src/main/res" } -
class Paparazzi( private val packageName: String, private val environment: Environment,
private val snapshotHandler: SnapshotHandler ) : TestRule data class Environment( val platformDir: String, val testDir: String ) { val testResDir = "$testDir/app/build/intermediates/classes/โฆ" val assetsDir = "$testDir/src/main/assets" val resDir = "$testDir/src/main/res" } -
internal class Renderer( private val env: Environment, โฆ ) :
Closeable { โฆ fun prepare(): SessionParamsBuilder { โฆ val projectRes = object : ResourceRepository(env.resDir, false) { override fun createResourceItem(name: String): ResourceItem { return ResourceItem(name) } } -
buildscript { dependencies { classpath 'app.cash.paparazzi:paparazzi-gradle-plugin:TODO' } } /build.gradle apply
plugin: 'app.cash.paparazzi ' /PROJECT/build.gradle -
class BitcoinUiTest { @get:Rule var paparazzi = Paparazzi("com.squareup.cash.investing.sample") @Test fun
investingGraphSample() { val sample = paparazzi.inflate<LinearLayout>(R.layout.graph_sample) val graphView = sample.findViewById<InvestingGraphView>(R.id.graph) graphView.render(DEFAULT_MODEL) paparazzi.snapshot(graphSample, "graph sample") } } -
class InvestingGraphView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr) { private val sparkView: SparkView by bindView(R.id.spark_view) private val eventLabel: TextView by bindView(R.id.spark_event_label) private val styler = InvestingGraphStyler( ContextCompat.getColor(context, R.color.investing_graph_line_color) ) private val graphAdapter = InvestingGraphAdapter(styler) var scrubListener: ((point: Point?) -> Unit)? = null var isSmoothed: Boolean get() = graphAdapter.smoothData set(value) { graphAdapter.smoothData = value } init { orientation = VERTICAL inflate(context, R.layout.investing_graph, this) -
fun render(viewModel: InvestingGraphContentModel) { when (viewModel) { is Error ->
TODO() is Loading -> { sparkView.isScrubEnabled = false graphAdapter.content = viewModel } is Loaded -> { sparkView.isScrubEnabled = true graphAdapter.content = viewModel } } } -
private val WIDTH = 360 val DEFAULT_MODEL = InvestingGraphContentModel.Loaded( graphWidth
= WIDTH.toFloat(), heightBoundPercentage = 0.20f, points = (1 until 80).map { x -> Point( x = x.toFloat(), y = Math.sin(x.toDouble() / 45).toFloat() + random(), effectFromPreviousPoint = NONE, treatment = PointTreatment.NONE, scrubText = null ) } ) -
investing/ โโโ components โ โโโ build.gradle โ โโโ src โ
โโโ main โ โโโ java โ โ โโโ com โ โ โโโ squareup โ โ โโโ cash โ โ โโโ investing โ โ โโโ components โ โ โโโ InvestingGraphView.kt โ โ โโโ graph โ โ โ โโโ CashSparkView.kt โ โ โ โโโ InvestingGraphAdapter.kt โ โ โ โโโ InvestingGraphPathType.kt โ โ โ โโโ InvestingGraphStyler.kt โ โ โโโ investingAdapters.kt โ โโโ res โ โโโ layout โ โ โโโ investing_graph.xml โ โโโ values -
โ โ โ โโโ InvestingGraphPathType.kt โ โ โ โโโ InvestingGraphStyler.kt
โ โ โโโ investingAdapters.kt โ โโโ res โ โโโ layout โ โ โโโ investing_graph.xml โ โโโ values โ โโโ colors.xml โ โโโ dimens.xml โ โโโ strings.xml โ โโโ styles.xml โโโ sample โ โโโ build.gradle โ โโโ src โ โโโ main โ โ โโโ res โ โ โโโ layout โ โ โ โโโ graph_sample.xml โ โ โโโ values โ โ โโโ strings.xml โ โ โโโ themes.xml โ โโโ test โ โโโ java โ โโโ com -
โ โ โโโ values โ โ โโโ strings.xml โ โ
โโโ themes.xml โ โโโ test โ โโโ java โ โโโ com โ โโโ squareup โ โโโ cash โ โโโ investing โ โโโ sample โ โโโ InvestingTest.kt โโโ viewmodels โโโ build.gradle โโโ src โโโ main โโโ java โโโ com โโโ squareup โโโ cash โโโ investing โโโ viewmodels โโโ InvestingGraphContentModel.kt -
class BitcoinUiTest { @get:Rule var paparazzi = Paparazzi("com.squareup.cash.investing.sample") @Test fun
investingGraphSample() { val sample = paparazzi.inflate<LinearLayout>(R.layout.graph_sample) val graphView = sample.findViewById<InvestingGraphView>(R.id.graph) graphView.render(DEFAULT_MODEL) paparazzi.snapshot(graphSample, "graph sample") } } -
class CardDesignViewTest { @get:Rule var paparazzi = Paparazzi("com.squareup.cash.ui.blockers") @Test fun
cardDesignPreview() { val cardDesign = paparazzi.inflate<ConstraintLayout>(R.layout.card_design_view) renderPreviewModel(cardDesign) paparazzi.snapshot(cardDesign, "card design preview") } @Test fun cardDesignCustomization() {โฆ} } -
@Test fun cardDesignCustomization() { val cardDesign = paparazzi.inflate<ConstraintLayout>(R.layout.card_design_view) val signatureView
= renderCustomizationView(cardDesign) signatureView.signature.startGlyph() var glyphCounter = 0 val signAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) signAnimator.duration = 3000 signAnimator.addUpdateListener { if (glyphCounter >= glyphs.size) return@addUpdateListener val e = glyphs[glyphCounter++] signatureView.signature.extendGlyph(e[0], e[1], e[2]) signatureView.signature.invalidateCurrentGlyph(signatureView) } signAnimator.addListener(object : AnimatorListener { override fun onAnimationEnd(animation: Animator?) { signatureView.signature.finishGlyph() } -
if (glyphCounter >= glyphs.size) return@addUpdateListener val e = glyphs[glyphCounter++] signatureView.signature.extendGlyph(e[0],
e[1], e[2]) signatureView.signature.invalidateCurrentGlyph(signatureView) } signAnimator.addListener(object : AnimatorListener { override fun onAnimationEnd(animation: Animator?) { signatureView.signature.finishGlyph() } }) signAnimator.start() paparazzi.gif( cardDesign, "card design customization", start = 0, end = signAnimator.duration, fps = 60 ) } -
if (glyphCounter >= glyphs.size) return@addUpdateListener val e = glyphs[glyphCounter++] signatureView.signature.extendGlyph(e[0],
e[1], e[2]) signatureView.signature.invalidateCurrentGlyph(signatureView) } signAnimator.addListener(object : AnimatorListener { override fun onAnimationEnd(animation: Animator?) { signatureView.signature.finishGlyph() } }) signAnimator.start() paparazzi.gif( cardDesign, "card design customization", start = 0, end = signAnimator.duration, fps = 60 ) }