diff --git a/.prettierignore b/.prettierignore index f3c08f37..52103794 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ pnpm-lock.yaml **/android/.gradle/** **/.bundle/** **/node_modules/** +packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js \ No newline at end of file diff --git a/implementations/PREVIEW_PANEL_SCENARIOS.md b/implementations/PREVIEW_PANEL_SCENARIOS.md new file mode 100644 index 00000000..7194bafd --- /dev/null +++ b/implementations/PREVIEW_PANEL_SCENARIOS.md @@ -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. diff --git a/implementations/ios-sdk/OptimizationApp/App.swift b/implementations/ios-sdk/OptimizationApp/App.swift index 20f8a660..f41956b1 100644 --- a/implementations/ios-sdk/OptimizationApp/App.swift +++ b/implementations/ios-sdk/OptimizationApp/App.swift @@ -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( diff --git a/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelOverridesTests.swift b/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelOverridesTests.swift new file mode 100644 index 00000000..b10d9e01 --- /dev/null +++ b/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelOverridesTests.swift @@ -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(). +} diff --git a/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelTests.swift b/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelTests.swift index 3f2ed4dc..f11b7973 100644 --- a/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelTests.swift +++ b/implementations/ios-sdk/OptimizationAppUITests/Tests/PreviewPanelTests.swift @@ -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.. + createClient({ + space: ENV_CONFIG.contentful.spaceId, + environment: ENV_CONFIG.contentful.environment, + accessToken: ENV_CONFIG.contentful.accessToken, + host: ENV_CONFIG.contentful.host, + basePath: ENV_CONFIG.contentful.basePath, + insecure: true, + }), + [], + ) const [entries, setEntries] = useState([]) const [sdkError, setSdkError] = useState(null) const [hasConsent, setHasConsent] = useState(false) @@ -116,39 +129,41 @@ function AppContent(): React.JSX.Element { } return ( - - - {!isIdentified ? ( -