Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ pnpm-lock.yaml
**/android/.gradle/**
**/.bundle/**
**/node_modules/**
packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js
187 changes: 187 additions & 0 deletions implementations/PREVIEW_PANEL_SCENARIOS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Preview Panel E2E Scenarios (Cross-Platform Contract)

This document is the **shared contract** driving preview-panel E2E tests on both React Native
(Detox) and iOS (XCUITest). Both suites MUST mirror scenario names, data, and expected observations
so cross-platform drift is immediately visible when diffing test output.

## Why these tests exist

The shared `PreviewOverrideManager`
(`packages/universal/core-sdk/src/lib/preview/PreviewOverrideManager.ts`) has strong unit coverage.
These E2E tests exist to verify the **thin platform wrappers** correctly:

1. Invoke manager methods from UI controls
2. Propagate signal changes through to rendered content
3. Preserve overrides across API refresh (interceptor path)

The E2E suite deliberately does **not** re-test manager logic (variant arithmetic, multi-index
scenarios, etc.) — that is already unit-covered.

## Harness requirements

**Preconditions for both platforms:**

- Mock server running at `localhost:8000` (`pnpm --filter @contentful/optimization-mocks serve`)
- Reference app identifies as an "identified visitor" so `identified-visitor.json` is served
- App launched fresh (no cached overrides)

**Assertion style:** rendered-content only. Each scenario drives preview-panel UI then observes
`entry-text-{entryId}` visibility. No debug-state labels are injected into the panel.

## Fixture data

All scenarios use:

- **Test audience**: `4yIqY7AWtzeehCZxtQSDB` ("Identified Users") — user qualifies naturally
- **Test experience**: `7DyidZaPB7Jr1gWKjoogg0` ("Personalization Nested Level 1"), audience-linked
to the test audience
- Baseline entry: `5i4SdJXw9oDEY0vgO7CwF4` — text "This is a level 1 nested baseline entry."
- Variant entry: `5a8ONfBdanJtlJ39WWnH1w` — text "This is a level 1 nested variant entry."
- **Secondary experience**: `6IueRX1pS3iMJncbhUQTba` ("Personalization Nested Level 2"), also
audience-linked
- Baseline entry: `uaNY4YJ0HFPAX3gKXiRdX` — "baseline level 2"
- Variant entry: `4hDiXxYEFrXHXcQgmdL9Uv` — "variant level 2"

Default state for an identified user: both experiences render their **variant** entries
(`5a8ONfBdanJtlJ39WWnH1w`, `4hDiXxYEFrXHXcQgmdL9Uv`). This is already asserted by
`displays-identified-user-variants.test.js` and `IdentifiedVariantsTests.swift` and serves as the
pre-test baseline.

## Shared accessibility identifiers / testIDs

| Control | ID |
| ---------------------------------- | ----------------------------------------------------- |
| Open preview panel (FAB) | `preview-panel-fab` |
| Close preview panel | `preview-panel-close` |
| Audience toggle (On/Default/Off) | `audience-toggle-{audienceId}-{on\|default\|off}` |
| Audience toggle container | `audience-toggle-{audienceId}` (RN only — radiogroup) |
| Variant picker (per option) | `variant-picker-{experienceId}-{index}` |
| Reset individual audience override | `reset-audience-{audienceId}` |
| Reset individual variant override | `reset-variant-{experienceId}` |
| Reset all overrides (footer) | `reset-all-overrides` |

Identifiers are identical on iOS (accessibilityIdentifier) and RN (testID). Keep them in sync when
adding new controls.

## Scenarios

Each scenario: open panel → drive UI → close panel → assert rendered content. `TEST_EXPERIENCE_ID`
below = `7DyidZaPB7Jr1gWKjoogg0`, `TEST_AUDIENCE_ID` = `4yIqY7AWtzeehCZxtQSDB`.

### 1. Activate an unqualified audience renders variants

Starting state: user is _not_ qualified for some audience X (pick one absent from
`profile.audiences` in the mock). Rendered entries linked to X show baseline.

Drive:

1. Tap `preview-panel-fab`
2. Tap `audience-toggle-{X}-on`
3. Tap `preview-panel-close`

Assert: entries linked to X now render their variant text.

> **Note**: requires an audience the identified user does NOT qualify for. If the mock's identified
> profile qualifies for every audience with content, extend the mock to add one unqualified
> audience + experience. Skip scenario if not yet possible; log as known gap.

### 2. Deactivate a qualified audience renders baselines

Starting state: user qualifies for `TEST_AUDIENCE_ID`, `TEST_EXPERIENCE_ID` renders variant
`5a8ONfBdanJtlJ39WWnH1w`.

Drive:

1. Tap `preview-panel-fab`
2. Tap `audience-toggle-4yIqY7AWtzeehCZxtQSDB-off`
3. Tap `preview-panel-close`

Assert: `entry-text-5i4SdJXw9oDEY0vgO7CwF4` (baseline) visible; variant no longer visible.

### 3. Reset audience override restores qualified state

Continuing from scenario 2 (audience is deactivated, baseline visible).

Drive:

1. Tap `preview-panel-fab`
2. Tap `audience-toggle-4yIqY7AWtzeehCZxtQSDB-default`
3. Tap `preview-panel-close`

Assert: `entry-text-5a8ONfBdanJtlJ39WWnH1w` (variant) visible again.

### 4. Set variant override to baseline renders baseline

Starting state: experience renders variant.

Drive:

1. Tap `preview-panel-fab`
2. Expand `TEST_AUDIENCE_ID` audience if needed
3. Tap `variant-picker-7DyidZaPB7Jr1gWKjoogg0-0`
4. Tap `preview-panel-close`

Assert: `entry-text-5i4SdJXw9oDEY0vgO7CwF4` (baseline) visible.

### 5. Reset single variant override restores default

Continuing from scenario 4 (variant override to index 0 set).

Drive:

1. Tap `preview-panel-fab`
2. Scroll to Overrides section
3. Tap `reset-variant-7DyidZaPB7Jr1gWKjoogg0`
4. Confirm the alert (iOS: "Reset" button; RN: Alert "Reset" button)
5. Tap `preview-panel-close`

Assert: variant content visible again.

### 6. Reset all overrides restores every experience

Setup: drive scenarios 2 + 4 so both an audience override and a variant override exist.

Drive:

1. Tap `preview-panel-fab`
2. Scroll to footer
3. Tap `reset-all-overrides`
4. Confirm alert ("Reset")
5. Tap `preview-panel-close`

Assert: all test experiences render their default (variant) content.

### 7. Override survives API refresh

Setup: drive scenario 2 (audience deactivated, baseline rendering).

Drive:

1. Tap `preview-panel-fab`
2. Tap `preview-refresh-button` (existing)
3. Tap `preview-panel-close`

Assert: baseline still rendering — the interceptor preserved the override through the API refresh.

### 8. Destroy/remount — overrides do not leak

Drive: set an override (scenario 2), close the app via platform API (`device.terminateApp()` /
`app.terminate()`), relaunch.

Assert: test experience renders default (variant) content; preview panel Overrides section empty.

## Running locally

- **RN (Android)**:
`pnpm --filter @contentful/optimization-react-native-reference-app test:e2e:android` (or iOS sim
equivalent)
- **iOS native**:
`xcodebuild test -scheme OptimizationApp -only-testing:OptimizationAppUITests/PreviewPanelOverridesTests -destination 'platform=iOS Simulator,name=iPhone 16'`

## Gaps / Known Limitations

- **Scenario 1** (activate unqualified audience) currently unverifiable unless the mock is extended
with an audience the identified user does not qualify for. Document as TODO on the test, or adjust
the mock profile.
- **Multi-index variant pickers**: all mock experiences are binary (index 0 or 1). Higher-index
arithmetic is unit-tested at manager level.
10 changes: 10 additions & 0 deletions implementations/ios-sdk/OptimizationApp/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import SwiftUI

@main
struct OptimizationDemoApp: App {
init() {
// UI tests launch with `--reset` to guarantee an unidentified-visitor
// starting state. The SDK persists `anonymousId`/profile in its own
// UserDefaults suite, and those outlive `terminate()` + `launch()` on
// the simulator — so an explicit wipe is the only reliable reset.
if ProcessInfo.processInfo.arguments.contains("--reset") {
UserDefaults.standard.removePersistentDomain(forName: "com.contentful.optimization")
}
}

var body: some Scene {
WindowGroup {
OptimizationRoot(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import XCTest

/// Cross-platform preview-panel override scenarios (iOS side).
///
/// Scenarios mirror `implementations/PREVIEW_PANEL_SCENARIOS.md` and the RN
/// Detox suite `preview-panel-overrides.test.js`. Keep test names and fixture
/// IDs identical across platforms so cross-platform regressions are visible.
final class PreviewPanelOverridesTests: XCTestCase {
let app = XCUIApplication()

static let AUDIENCE_ID = "4yIqY7AWtzeehCZxtQSDB"
static let EXPERIENCE_ID = "7DyidZaPB7Jr1gWKjoogg0"
static let VARIANT_ENTRY_ID = "5a8ONfBdanJtlJ39WWnH1w"
static let BASELINE_ENTRY_ID = "5i4SdJXw9oDEY0vgO7CwF4"

override func setUp() {
continueAfterFailure = false
app.launch()
clearProfileState(app: app)
identifyAndWaitForEntries()
}

// MARK: - Helpers

private func identifyAndWaitForEntries() {
let identifyButton = app.buttons["identify-button"]
waitForElement(identifyButton)
identifyButton.tap()
waitForElement(app.buttons["reset-button"])

// Identified-visitor profile should render variant entries by default.
let variantEntry = findElement("entry-text-\(Self.VARIANT_ENTRY_ID)", app: app)
XCTAssertTrue(variantEntry.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Expected variant entry to render after identify")
}

private func openPanel() {
let fab = app.buttons["preview-panel-fab"]
waitForElement(fab)
fab.tap()
XCTAssertTrue(app.staticTexts["Preview Panel"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Preview Panel did not appear")
}

private func closePanel() {
// The panel sheet is dismissed via the drag handle or close button;
// sheets can also be dismissed by swiping down on the header.
let dismissGesture = app.navigationBars.buttons.firstMatch
if dismissGesture.exists {
dismissGesture.tap()
return
}
// Fallback: swipe down from top of sheet to dismiss.
app.swipeDown()
}

private func assertEntryVisible(_ entryId: String, message: String) {
let entry = findElement("entry-text-\(entryId)", app: app)
XCTAssertTrue(entry.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), message)
}

// MARK: - Scenarios

func testScenario2DeactivatingQualifiedAudienceRendersBaseline() {
openPanel()
let toggle = app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-off"]
XCTAssertTrue(toggle.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Off toggle not found for audience")
toggle.tap()
closePanel()

assertEntryVisible(Self.BASELINE_ENTRY_ID,
message: "Expected baseline entry after deactivating audience")
}

func testScenario3ResettingAudienceOverrideRestoresVariant() {
// Set up by first deactivating, then resetting to default.
openPanel()
app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-off"].tap()
app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-default"].tap()
closePanel()

assertEntryVisible(Self.VARIANT_ENTRY_ID,
message: "Expected variant entry after resetting audience override")
}

func testScenario4SettingVariantOverrideToZeroRendersBaseline() {
openPanel()
let picker = app.buttons["variant-picker-\(Self.EXPERIENCE_ID)-0"]
XCTAssertTrue(picker.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Variant picker baseline option not found")
picker.tap()
closePanel()

assertEntryVisible(Self.BASELINE_ENTRY_ID,
message: "Expected baseline after variant-0 override")
}

func testScenario6ResetAllRestoresVariantContent() {
// Apply a variant override, then reset all.
openPanel()
app.buttons["variant-picker-\(Self.EXPERIENCE_ID)-0"].tap()
let resetAll = app.buttons["reset-all-overrides"]
XCTAssertTrue(resetAll.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Reset-all button not found")
resetAll.tap()

// Confirm the alert.
let resetButton = app.alerts.buttons["Reset"]
XCTAssertTrue(resetButton.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT),
"Reset confirmation button not found")
resetButton.tap()
closePanel()

assertEntryVisible(Self.VARIANT_ENTRY_ID,
message: "Expected variant entry after reset-all")
}

// TODO: scenarios 1, 5, 7, 8 — see implementations/PREVIEW_PANEL_SCENARIOS.md.
// - 1 (activate unqualified audience) requires a mock audience the identified user does not qualify for.
// - 5 (reset single variant override) taps the per-item reset button in the Overrides section.
// - 7 (override survives API refresh) drives preview-refresh-button between open/close.
// - 8 (destroy/remount) uses app.terminate() + app.launch().
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@ final class PreviewPanelTests: XCTestCase {
}

/// Scrolls the preview panel list until the target element is visible.
/// SwiftUI List renders as collectionView; plain ScrollView as scrollView.
/// Prefer an identifier match; fall back to the first match of either type.
private func scrollToPreviewElement(_ testId: String, maxSwipes: Int = 10) {
// SwiftUI List renders as collectionView on modern iOS
let list = app.collectionViews["preview-panel-list"]
let scrollContainer = list.exists ? list : app.collectionViews.firstMatch
let listById = app.collectionViews["preview-panel-list"]
let scrollById = app.scrollViews["preview-panel-list"]
let scrollContainer: XCUIElement
if listById.exists {
scrollContainer = listById
} else if scrollById.exists {
scrollContainer = scrollById
} else if app.collectionViews.firstMatch.exists {
scrollContainer = app.collectionViews.firstMatch
} else {
scrollContainer = app.scrollViews.firstMatch
}
for _ in 0..<maxSwipes {
let target = findElement(testId, app: app)
if target.exists && target.isHittable { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ final class UnidentifiedVariantsTests: XCTestCase {

override func setUp() {
continueAfterFailure = false
app.launch()
clearProfileState(app: app)
// These tests assert the new-visitor mock fixture is served, which the
// mock server routes by the persisted `anonymousId`. An in-app reset
// via the button only clears state when the user is currently
// identified — if the simulator already booted into an unidentified
// surface, `clearProfileState` returns immediately and any stale
// `anonymousId` stays around, so the mock keeps serving the
// identified-visitor fixture. `requireFreshAppInstance: true` triggers
// `--reset`, which wipes the SDK's UserDefaults suite on launch.
clearProfileState(app: app, requireFreshAppInstance: true)
}

func testDisplaysMergeTagEntry() {
Expand Down
Loading
Loading