Introduce offline paywall snapshot testing (purchases-ui-testing + recorder plugin)#3622
Introduce offline paywall snapshot testing (purchases-ui-testing + recorder plugin)#3622skydoves wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a637a01. Configure here.
| } | ||
| } | ||
| return response.put("offerings", kept) | ||
| } |
There was a problem hiding this comment.
Stale current offering after filter
Medium Severity
After filterOfferings drops offerings without paywall_components, the recorded offerings.json still keeps the API’s current_offering_id verbatim. When that id was removed, FixtureOfferingsFactory leaves Offerings.current null, so PaywallFixtures.offering() without an id fails even though valid component offerings were recorded.
Reviewed by Cursor Bugbot for commit a637a01. Configure here.
| // image, rendering will surface a clear "fixture image missing" error. | ||
| log("Skipping asset (HTTP $responseCode): $url") | ||
| return false | ||
| } |
There was a problem hiding this comment.
Skipped 404 keeps old fixture image
Low Severity
When asset download gets HTTP 4xx, downloadAsset logs and returns without deleting an existing mirrored file. Re-running recordPaywallFixtures without --refresh can leave a previous image on disk while offerings.json changes, so offline snapshots may show outdated assets instead of failing clearly.
Reviewed by Cursor Bugbot for commit a637a01. Configure here.
Danger has errored[!] Invalid Generated by 🚫 Danger |
📸 Snapshot Test21 modified, 9 added, 1 removed, 571 unchanged
🛸 Powered by Emerge Tools |
1 build increased size, 1 build decreased size
TestPurchasesUIAndroidCompatibility 1.0 (1)
|
| Item | Install Size Change | Download Size Change |
|---|---|---|
| 🗑 1181742_1751450815.heic | ⬇️ -465.0 kB | ⬇️ -465.0 kB |
| 🗑 1181742_1751450815.webp | ⬇️ -157.6 kB | ⬇️ -154.9 kB |
| offerings.json | ⬇️ -19.1 kB | ⬇️ -17.5 kB |
| cpu.heic | ⬇️ -2.7 kB | ⬇️ -2.7 kB |
| archive.heic | ⬇️ -2.5 kB | ⬇️ -2.5 kB |
SDKSizeTesting 1.0 (1)
com.revenuecat.testapps.sdksizetesting
⚖️ Compare build
⏱️ Analyze build performance
Total install size change: ⬆️ 20.8 kB (0.06%)
Total download size change: ⬆️ 10.7 kB (0.09%)
Largest size changes
| Item | Install Size Change | Download Size Change |
|---|---|---|
| 📝 com.revenuecat.purchases.ui.revenuecatui.testing.InternalPaywallF... | ⬆️ 3.3 kB | ⬆️ 1.1 kB |
| Other | ⬆️ 17.5 kB | ⬆️ 9.7 kB |
🛸 Powered by Emerge Tools
Comment trigger: Size diff threshold of 100.00kB exceeded
tonidero
left a comment
There was a problem hiding this comment.
Still reviewing but looking great so far!!
| @@ -0,0 +1,56 @@ | |||
| package com.revenuecat.purchases.paywallfixtures.internal | |||
There was a problem hiding this comment.
[Not related to this line]
I've been thinking whether it could make sense to rename this module to something more generic like ui-fixtures-plugin? Mostly thinking about eventually supporting the different screens for workflows...
| val kept = JSONArray() | ||
| for (i in 0 until offerings.length()) { | ||
| val offering = offerings.getJSONObject(i) | ||
| val hasComponentsPaywall = offering.has("paywall_components") |
There was a problem hiding this comment.
I think we will need to change this soon, due to workflows (since information about paywalls will come from a separate endpoint) cc @vegaro . Should hopefully be not a huge change 🤞




SDK consumers currently have no way to snapshot test their server driven paywalls (Paywalls V2 "components"). The only public entry point,
Paywall(options), needs a configuredPurchasesinstance, a network call for offerings, and Google Play Billing products, so it cannot run in a JVM unit test.This PR adds a small, supported way to render a consumer's paywall fully offline (no
Purchases.configure(), no network, no Billing) so it can be captured with Paparazzi or Roborazzi:purchases-ui-testing, that renders a paywall from a recorded fixture.The render path reuses the exact same composable the SDK uses in production (
LoadedPaywallComponents), so the snapshot matches what the app renders, with the only fabricated input being prices (which are not part of the offerings response).There are new modules:
:ui:revenuecatui-testing(artifactpurchases-ui-testing): the consumer facing API.PaywallFixtures.load(),PaywallFixtureView,PaywallFixtureViewOptions, andPaywallFixturesTestRule.:paywall-fixtures-plugin(plugin idcom.revenuecat.purchases.paywallfixtures): registers therecordPaywallFixturestask and, when the consumer applies Paparazzi, wires up the test dependency andtestOptions.Supporting changes:
:purchases:FixtureOfferingsFactory(annotated@InternalRevenueCatAPI) buildsOfferingsfrom an offerings shaped JSON, substitutingTestStoreProducts for real store products. The hardcoded preview products were extracted so the previews and the factory share them.:ui:revenuecatui: a thin internal bridge (ComponentsPaywallForTesting, also@InternalRevenueCatAPI) that runs validate then state thenLoadedPaywallComponentswith an injected image loader. Promoting these internals as hidden API keepsapi.txtunchanged.examples/paywall-tester: a consumer perspectivePaywallSnapshotTestplus committed snapshot images, exercising only the kit's public API.Setup and usage
1. Version catalog
The plugin and the kit are versioned with the SDK, so use the same version as your
purchasesdependency. Pick a Paparazzi version compatible with your AGP.2. Module build.gradle.kts
Apply Paparazzi (your chosen version) and the recorder plugin. The kit test dependency and
testOptions.unitTests.isIncludeAndroidResourcesare wired automatically when Paparazzi is present.plugins { alias(libs.plugins.paparazzi) alias(libs.plugins.paywallfixtures) }3. The test
4. Recording fixtures (where the API key is used)
recordPaywallFixturescalls the RevenueCat offerings API to download the offerings JSON and the paywall images intosrc/test/resources/revenuecat-paywall-fixtures. It needs your app's public SDK key, the same key you pass toPurchases.configure(for examplegoog_...orappl_...). The key is used only at record time. The snapshot tests themselves render offline from the committed fixtures and need no key.Provide the key without committing it, either via the
REVENUECAT_API_KEYenvironment variable (the default source):or via the extension, for example reading a local Gradle property:
Then record (or verify) the snapshots:
5. Opting out
The plugin is lean: it does not bring its own Paparazzi, so applying it never forces a Paparazzi version onto your classpath. To skip the auto wiring (for example to use Roborazzi, or to manage the test dependency and
testOptionsyourself), setpaywallFixtures { setupSnapshotTesting = false }or pass-Prevenuecat.paywallFixtures.snapshotTesting=false.Production code changes to review
Two changes touch shipped
:ui:revenuecatuicode and deserve attention:RemoteImageandBackgroundStylenow key off "an image loader was injected" rather thanLocalInspectionMode. Production behavior is unchanged because nothing in production provides the loader. This is the change that lets the published (release) artifact render fixture images.PaywallResourceProvider.getXmlFontFamilynow returns null instead of crashing when the platformgetXmlreturns null. layoutlib (Paparazzi) returns null where a device throwsNotFoundException, which previously surfaced as a Kotlin intrinsic NPE. The kit also forces fonts to a system fallback during offline rendering so tests do not depend on the consumer bundling the dashboard's fonts.Note
Medium Risk
Touches shipped revenuecatui image and font resolution paths (guarded by injected loader) and adds new published APIs/plugins; production paywall rendering should be unchanged when consumers do not opt into the testing kit.
Overview
Adds offline JVM snapshot testing for Paywalls V2 (components) so apps can golden-test dashboard paywalls without
Purchases.configure(), network, or Play Billing.New consumer surface: published
purchases-ui-testing(PaywallFixtures,PaywallFixtureView,PaywallFixturesTestRule) loads recordedofferings.json+ mirrored CDN images from test resources and renders through the sameLoadedPaywallComponentspath as production, with fabricatedTestStoreProductprices.com.revenuecat.purchases.uifixturesregistersrecordPaywallFixtures(offerings API + asset download) and optionally wires Paparazzi test deps when Paparazzi is applied.Core SDK support:
FixtureOfferingsFactorybuildsOfferingsfrom offerings-shaped JSON; preview defaults move toTestStoreProductDefaults.ComponentsPaywallForTesting(internal) injects a classpath image loader, fixed date, mock purchases, and font fallback for Paparazzi.Production
revenuecatuitweaks (behavior unchanged when no loader is injected): preview image loading keys offLocalPreviewImageLoaderinstead of inspection mode (moved to main; release no-ops removed);getXmlFontFamilytolerates layoutlib returning null.Example:
paywall-testergets parameterized Paparazzi tests; regenerated fixture trees under test resources are gitignored while snapshot images stay committed.Reviewed by Cursor Bugbot for commit c2b6f2c. Bugbot is set up for automated code reviews on this repo. Configure here.