From bab6fe7ab70be4c6dc31b3036d5027383e1385d0 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 16:51:27 -0300 Subject: [PATCH 1/6] feat(surveys): auto-submit rating and single-choice selections Decode skipSubmitButton and expose it on public display questions. Hide the submit button and advance when a rating or eligible single choice is selected. Retain explicit submission for multi-select and open-choice questions, matching posthog-js. Reset selection state between consecutive eligible questions. Verified red/green mapping and eligibility tests; 79 survey tests on macOS; 84 survey tests on iOS 26.5 simulator; make lint; updated public API snapshot. CodeScene quality gate passed; legacy test-file health remains stable. --- .changeset/legal-eagles-tie.md | 5 ++++ .../Surveys/PostHogSurvey+Display.swift | 6 ++-- .../Surveys/PostHogSurveyQuestion.swift | 2 ++ .../Models/PostHogDisplaySurveyQuestion.swift | 14 +++++++-- PostHog/Surveys/QuestionTypes.swift | 24 +++++++++++---- PostHog/Surveys/SurveySheet.swift | 2 ++ PostHogTests/PostHogSurveysTest.swift | 29 +++++++++++++++++++ api/posthog-ios.public-api.txt | 2 ++ 8 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 .changeset/legal-eagles-tie.md diff --git a/.changeset/legal-eagles-tie.md b/.changeset/legal-eagles-tie.md new file mode 100644 index 0000000000..8af7775b37 --- /dev/null +++ b/.changeset/legal-eagles-tie.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": minor +--- + +Support survey auto-submit on selection. Honor skipSubmitButton for ratings and single-choice questions without an open-ended choice, matching posthog-js, and expose the setting to custom survey delegates. diff --git a/PostHog/Models/Surveys/PostHogSurvey+Display.swift b/PostHog/Models/Surveys/PostHogSurvey+Display.swift index faaa720f5e..9fe306b224 100644 --- a/PostHog/Models/Surveys/PostHogSurvey+Display.swift +++ b/PostHog/Models/Surveys/PostHogSurvey+Display.swift @@ -57,7 +57,8 @@ scaleLowerBound: question.scale.range.lowerBound, scaleUpperBound: question.scale.range.upperBound, lowerBoundLabel: translation?.lowerBoundLabel ?? question.lowerBoundLabel ?? "", - upperBoundLabel: translation?.upperBoundLabel ?? question.upperBoundLabel ?? "" + upperBoundLabel: translation?.upperBoundLabel ?? question.upperBoundLabel ?? "", + skipSubmitButton: question.skipSubmitButton ?? false ) case let .singleChoice(question), let .multipleChoice(question): @@ -71,7 +72,8 @@ choices: translation?.choices ?? question.choices, hasOpenChoice: question.hasOpenChoice ?? false, shuffleOptions: question.shuffleOptions ?? false, - isMultipleChoice: isMultipleChoice + isMultipleChoice: isMultipleChoice, + skipSubmitButton: question.skipSubmitButton ?? false ) default: diff --git a/PostHog/Models/Surveys/PostHogSurveyQuestion.swift b/PostHog/Models/Surveys/PostHogSurveyQuestion.swift index c33c03eae1..b6f0ef445e 100644 --- a/PostHog/Models/Surveys/PostHogSurveyQuestion.swift +++ b/PostHog/Models/Surveys/PostHogSurveyQuestion.swift @@ -156,6 +156,7 @@ struct PostHogRatingSurveyQuestion: PostHogSurveyQuestionProperties, Decodable { let scale: PostHogSurveyRatingScale let lowerBoundLabel: String? let upperBoundLabel: String? + var skipSubmitButton: Bool? = nil } /// Represents a multiple-choice or single-choice survey question @@ -175,6 +176,7 @@ struct PostHogMultipleSurveyQuestion: PostHogSurveyQuestionProperties, Decodable let hasOpenChoice: Bool? /// Indicates if choices should be shuffled or not (optional) let shuffleOptions: Bool? + var skipSubmitButton: Bool? = nil } /// Represents branching logic for a question based on user responses diff --git a/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift b/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift index bd59ed810e..1e2fd49fba 100644 --- a/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift +++ b/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift @@ -81,6 +81,8 @@ import Foundation public let lowerBoundLabel: String /// The label for the upper bound of the rating scale public let upperBoundLabel: String + /// Whether selecting a rating submits the answer without a submit button + public let skipSubmitButton: Bool init( id: String, @@ -93,13 +95,15 @@ import Foundation scaleLowerBound: Int, scaleUpperBound: Int, lowerBoundLabel: String, - upperBoundLabel: String + upperBoundLabel: String, + skipSubmitButton: Bool = false ) { self.ratingType = ratingType self.scaleLowerBound = scaleLowerBound self.scaleUpperBound = scaleUpperBound self.lowerBoundLabel = lowerBoundLabel self.upperBoundLabel = upperBoundLabel + self.skipSubmitButton = skipSubmitButton super.init( id: id, question: question, @@ -121,6 +125,10 @@ import Foundation public let shuffleOptions: Bool /// Whether the user can select multiple options public let isMultipleChoice: Bool + /// Whether selection should submit automatically for single-choice questions without an open choice + public let skipSubmitButton: Bool + + var shouldAutoSubmit: Bool { skipSubmitButton && !isMultipleChoice && !hasOpenChoice } init( id: String, @@ -132,12 +140,14 @@ import Foundation choices: [String], hasOpenChoice: Bool, shuffleOptions: Bool, - isMultipleChoice: Bool + isMultipleChoice: Bool, + skipSubmitButton: Bool = false ) { self.choices = choices self.hasOpenChoice = hasOpenChoice self.shuffleOptions = shuffleOptions self.isMultipleChoice = isMultipleChoice + self.skipSubmitButton = skipSubmitButton super.init( id: id, question: question, diff --git a/PostHog/Surveys/QuestionTypes.swift b/PostHog/Surveys/QuestionTypes.swift index a4928e1912..1c9446a1e5 100644 --- a/PostHog/Surveys/QuestionTypes.swift +++ b/PostHog/Surveys/QuestionTypes.swift @@ -145,10 +145,17 @@ ) } - BottomSection(label: question.buttonText ?? appearance.submitButtonText) { - onNextQuestion(rating) + if !question.skipSubmitButton { + BottomSection(label: question.buttonText ?? appearance.submitButtonText) { + onNextQuestion(rating) + } + .disabled(!canSubmit) + } + } + .onChange(of: rating) { value in + if question.skipSubmitButton, let value { + onNextQuestion(value) } - .disabled(!canSubmit) } } @@ -188,10 +195,17 @@ openChoiceInput: $openChoiceInput ) - BottomSection(label: question.buttonText ?? appearance.submitButtonText) { + if !question.shouldAutoSubmit { + BottomSection(label: question.buttonText ?? appearance.submitButtonText) { + onNextQuestion(response) + } + .disabled(!canSubmit) + } + } + .onChange(of: selectedChoices) { _ in + if question.shouldAutoSubmit, let response { onNextQuestion(response) } - .disabled(!canSubmit) } } diff --git a/PostHog/Surveys/SurveySheet.swift b/PostHog/Surveys/SurveySheet.swift index 8d54b346e1..37e783b84b 100644 --- a/PostHog/Surveys/SurveySheet.swift +++ b/PostHog/Surveys/SurveySheet.swift @@ -59,6 +59,7 @@ RatingQuestionView(question: currentQuestion) { resp in displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .rating(resp)) } + .id(currentQuestion.id) case let currentQuestion as PostHogDisplayChoiceQuestion: if currentQuestion.isMultipleChoice { MultipleChoiceQuestionView(question: currentQuestion) { resp in @@ -68,6 +69,7 @@ SingleChoiceQuestionView(question: currentQuestion) { resp in displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .singleChoice(resp)) } + .id(currentQuestion.id) } default: EmptyView() diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 1fd2762a3c..441afca9f8 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -13,6 +13,35 @@ import Testing @Suite("Test Surveys") enum PostHogSurveysTest { + @Suite("Auto-submit on selection") + struct AutoSubmit { + private func displayQuestion(type: String, skipSubmit: Bool?, hasOpenChoice: Bool = false, display: String = "number") throws -> PostHogDisplaySurveyQuestion { + var json: [String: Any] = [ + "id": "auto-submit", "type": type, "question": "Choose", + "choices": ["First", "Other"], "hasOpenChoice": hasOpenChoice, + "display": display, "scale": 5, + ] + json["skipSubmitButton"] = skipSubmit + let question = try PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: JSONSerialization.data(withJSONObject: json)) + return try #require(question.toDisplayQuestion()) + } + + @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) + func rating(skipSubmit: Bool?, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) + } + + @Test("only single choice without an open choice auto-submits", arguments: [true, false, nil] as [Bool?], [false, true]) + func choices(skipSubmit: Bool?, hasOpenChoice: Bool) throws { + for type in ["single_choice", "multiple_choice"] { + let question = try #require(displayQuestion(type: type, skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) + #expect(question.shouldAutoSubmit == (skipSubmit == true && type == "single_choice" && !hasOpenChoice)) + } + } + } + @Suite("Test decoding surveys from remote config") struct TestDecodingSurveys { @Test("survey decodes correctly") diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 19e4d45a3c..d881aaccb5 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -111,6 +111,7 @@ PostHog | PostHogDisplayChoiceQuestion.choices | property | let choices: [String PostHog | PostHogDisplayChoiceQuestion.hasOpenChoice | property | let hasOpenChoice: Bool | s:7PostHog0aB21DisplayChoiceQuestionC07hasOpenD0Sbvp PostHog | PostHogDisplayChoiceQuestion.isMultipleChoice | property | let isMultipleChoice: Bool | s:7PostHog0aB21DisplayChoiceQuestionC010isMultipleD0Sbvp PostHog | PostHogDisplayChoiceQuestion.shuffleOptions | property | let shuffleOptions: Bool | s:7PostHog0aB21DisplayChoiceQuestionC14shuffleOptionsSbvp +PostHog | PostHogDisplayChoiceQuestion.skipSubmitButton | property | let skipSubmitButton: Bool | s:7PostHog0aB21DisplayChoiceQuestionC16skipSubmitButtonSbvp PostHog | PostHogDisplayLinkQuestion | class | @objc class PostHogDisplayLinkQuestion | c:@M@PostHog@objc(cs)PostHogDisplayLinkQuestion PostHog | PostHogDisplayLinkQuestion.link | property | let link: String? | s:7PostHog0aB19DisplayLinkQuestionC4linkSSSgvp PostHog | PostHogDisplayOpenQuestion | class | @objc class PostHogDisplayOpenQuestion | c:@M@PostHog@objc(cs)PostHogDisplayOpenQuestion @@ -119,6 +120,7 @@ PostHog | PostHogDisplayRatingQuestion.lowerBoundLabel | property | let lowerBou PostHog | PostHogDisplayRatingQuestion.ratingType | property | let ratingType: PostHogDisplaySurveyRatingType | s:7PostHog0aB21DisplayRatingQuestionC10ratingTypeAA0abc6SurveydG0Ovp PostHog | PostHogDisplayRatingQuestion.scaleLowerBound | property | let scaleLowerBound: Int | s:7PostHog0aB21DisplayRatingQuestionC15scaleLowerBoundSivp PostHog | PostHogDisplayRatingQuestion.scaleUpperBound | property | let scaleUpperBound: Int | s:7PostHog0aB21DisplayRatingQuestionC15scaleUpperBoundSivp +PostHog | PostHogDisplayRatingQuestion.skipSubmitButton | property | let skipSubmitButton: Bool | s:7PostHog0aB21DisplayRatingQuestionC16skipSubmitButtonSbvp PostHog | PostHogDisplayRatingQuestion.upperBoundLabel | property | let upperBoundLabel: String | s:7PostHog0aB21DisplayRatingQuestionC15upperBoundLabelSSvp PostHog | PostHogDisplaySurvey | class | @objc class PostHogDisplaySurvey | c:@M@PostHog@objc(cs)PostHogDisplaySurvey PostHog | PostHogDisplaySurvey.appearance | property | let appearance: PostHogDisplaySurveyAppearance? | s:7PostHog0aB13DisplaySurveyC10appearanceAA0abcD10AppearanceCSgvp From 2f5f1042980e8dc48c7b21a0a4f3946c8359ba59 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 16:58:50 -0300 Subject: [PATCH 2/6] test(surveys): await targeting flags before matching CI exposed a race between forced survey loading and the remote-config listener's flag refresh. Seed survey data separately and await flag loading, matching the neighboring feature-flag eligibility test. The matching suite passes (10 tests), and the previously failing test passes 10 consecutive runs. CodeScene gate passed with stable test-file health. --- PostHogTests/PostHogSurveysTest.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 441afca9f8..298d468371 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -1636,11 +1636,20 @@ enum PostHogSurveysTest { } @Test("returns surveys that match internal targeting flags") - func returnsSurveysThatMatchInternalTargetingFlags() async { + func returnsSurveysThatMatchInternalTargetingFlags() async throws { let sut = getSut(surveys: [surveyWithEnabledInternalTargetingFlag]) + let surveys = sut.decodeSurveys(from: [ + "surveys": try parseSurveys(surveyWithEnabledInternalTargetingFlag), + ]) + sut.updateSurveyCache(surveys, events: [:]) + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in + continuation.resume() + } + } let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { + sut.getActiveMatchingSurveys { continuation.resume(with: .success($0)) } } From e112bb983aaa845d7f2e89e319ee048f41f17986 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 17:20:01 -0300 Subject: [PATCH 3/6] test(surveys): cover immediate selection callbacks Submit from the selection bindings used by numeric/emoji ratings and single-choice controls. This makes auto-submit synchronous with selection and allows direct callback regression tests without new dependencies. Cover eligible and disabled flags, open options, answer values, and clearing a selection. Keep explicit submission and per-question view identity. Verification: make testOniOSSimulator and make lint passed. CodeScene gate passed; the existing large survey test file stayed stable. Tests exercise the production bindings, not automated taps or visual button visibility. --- .../Surveys/PostHogSurveyQuestion.swift | 4 +-- PostHog/Surveys/QuestionTypes.swift | 30 +++++++++++++------ PostHogTests/PostHogSurveysTest.swift | 29 ++++++++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/PostHog/Models/Surveys/PostHogSurveyQuestion.swift b/PostHog/Models/Surveys/PostHogSurveyQuestion.swift index b6f0ef445e..a0c6fb2d8b 100644 --- a/PostHog/Models/Surveys/PostHogSurveyQuestion.swift +++ b/PostHog/Models/Surveys/PostHogSurveyQuestion.swift @@ -156,7 +156,7 @@ struct PostHogRatingSurveyQuestion: PostHogSurveyQuestionProperties, Decodable { let scale: PostHogSurveyRatingScale let lowerBoundLabel: String? let upperBoundLabel: String? - var skipSubmitButton: Bool? = nil + var skipSubmitButton: Bool? } /// Represents a multiple-choice or single-choice survey question @@ -176,7 +176,7 @@ struct PostHogMultipleSurveyQuestion: PostHogSurveyQuestionProperties, Decodable let hasOpenChoice: Bool? /// Indicates if choices should be shuffled or not (optional) let shuffleOptions: Bool? - var skipSubmitButton: Bool? = nil + var skipSubmitButton: Bool? } /// Represents branching logic for a question based on user responses diff --git a/PostHog/Surveys/QuestionTypes.swift b/PostHog/Surveys/QuestionTypes.swift index 1c9446a1e5..7155befde3 100644 --- a/PostHog/Surveys/QuestionTypes.swift +++ b/PostHog/Surveys/QuestionTypes.swift @@ -131,14 +131,14 @@ if question.ratingType == .emoji { EmojiRating( - selectedValue: $rating, + selectedValue: selection, scale: scale, lowerBoundLabel: question.lowerBoundLabel, upperBoundLabel: question.upperBoundLabel ) } else { NumberRating( - selectedValue: $rating, + selectedValue: selection, scale: scale, lowerBoundLabel: question.lowerBoundLabel, upperBoundLabel: question.upperBoundLabel @@ -152,11 +152,15 @@ .disabled(!canSubmit) } } - .onChange(of: rating) { value in + } + + var selection: Binding { + Binding(get: { rating }, set: { value in + rating = value if question.skipSubmitButton, let value { onNextQuestion(value) } - } + }) } private var canSubmit: Bool { @@ -191,7 +195,7 @@ allowsMultipleSelection: false, hasOpenChoiceQuestion: question.hasOpenChoice, options: question.choices, - selectedOptions: $selectedChoices, + selectedOptions: selection, openChoiceInput: $openChoiceInput ) @@ -202,15 +206,23 @@ .disabled(!canSubmit) } } - .onChange(of: selectedChoices) { _ in - if question.shouldAutoSubmit, let response { + } + + var selection: Binding> { + Binding(get: { selectedChoices }, set: { value in + selectedChoices = value + if question.shouldAutoSubmit, let response = response(for: value) { onNextQuestion(response) } - } + }) } private var response: String? { - guard let index = selectedChoices.first, index < question.choices.count else { return nil } + response(for: selectedChoices) + } + + private func response(for selectedChoices: Set) -> String? { + guard let index = selectedChoices.first, question.choices.indices.contains(index) else { return nil } if index == openChoiceIndex(for: question) { return openChoiceInput.trimmingCharacters(in: .whitespaces) } diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 298d468371..bcf05b3b7f 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -26,6 +26,35 @@ enum PostHogSurveysTest { return try #require(question.toDisplayQuestion()) } + #if os(iOS) + @Test("rating selection emits one answer immediately and clearing emits none", arguments: [true, false], ["number", "emoji"]) + @available(iOS 15.0, *) + @MainActor + func ratingSelection(skipSubmit: Bool, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + var responses: [Int?] = [] + let view = RatingQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = 4 + #expect(responses == (skipSubmit ? [4] : [])) + view.selection.wrappedValue = nil + #expect(responses == (skipSubmit ? [4] : [])) + } + + @Test("single-choice selection emits the answer only when eligible", arguments: [true, false], [false, true]) + @available(iOS 15.0, *) + @MainActor + func choiceSelection(skipSubmit: Bool, hasOpenChoice: Bool) throws { + let question = try #require(displayQuestion(type: "single_choice", skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + var responses: [String?] = [] + let view = SingleChoiceQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = [0] + let expected: [String?] = skipSubmit && !hasOpenChoice ? ["First"] : [] + #expect(responses == expected) + view.selection.wrappedValue = [] + #expect(responses == expected) + } + #endif + @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) func rating(skipSubmit: Bool?, display: String) throws { let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) From d66c7083a5478bd2baec579928d737ba16320efc Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 11:21:14 -0300 Subject: [PATCH 4/6] test(surveys): exercise auto-submit with real iOS interactions Add a small host for the SDK's SurveySheet and SurveyDisplayController, and XCUITest coverage for rating and choice taps, manual/open-choice fallbacks, exactly-once responses, branching, and selection reset. Run the UI suite explicitly in CI through make testSurveyUI. Validation: seven UI tests passed, including six manual flag/type cases. A temporary removal of the question identity reset failed the numeric stale-selection assertion; the restored implementation passed. make test, make format, make lint, make apiCheck, and CodeScene passed. --- .github/workflows/test.yml | 3 + .gitignore | 3 + .swiftlint.yml | 1 + Makefile | 12 +- PostHog.xcodeproj/project.pbxproj | 346 ++++++++++++++++++ .../xcschemes/PostHogSurveyUI.xcscheme | 98 +++++ PostHogSurveyUITests/Host/SurveyTestApp.swift | 65 ++++ PostHogSurveyUITests/README.md | 18 + .../SurveyAutoSubmitUITests.swift | 136 +++++++ 9 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 PostHog.xcodeproj/xcshareddata/xcschemes/PostHogSurveyUI.xcscheme create mode 100644 PostHogSurveyUITests/Host/SurveyTestApp.swift create mode 100644 PostHogSurveyUITests/README.md create mode 100644 PostHogSurveyUITests/SurveyAutoSubmitUITests.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56f1f8ed4c..8925723c0c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,9 @@ jobs: # Retries (3 iterations) protect merges from transient simulator flakiness; the macOS `test` # job runs without retries, so a genuine flake still surfaces as a hard failure there. run: make testOniOSSimulator + - name: Test survey interactions on iOS Simulator + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: make testSurveyUI - name: Report flaky (retried) tests # Retries can hide flakiness by turning a transient red into green. Surface any test that only # passed after a retry so flakes get tracked and fixed instead of silently masked. Best-effort: diff --git a/.gitignore b/.gitignore index fea72bafd9..2bdd1da954 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ PostHogTests/__MaskSnapshotFailures__/ mask-snapshots.log # Raw xcodebuild log the testOniOSSimulator target tees for the CI retry check xcodebuild-ios.log + +# Survey UI test run output +survey-ui-tests.log diff --git a/.swiftlint.yml b/.swiftlint.yml index 070477ef02..639bbe95da 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -12,6 +12,7 @@ excluded: # case-sensitive paths to ignore during linting. Takes precedence over - PostHogExampleWithPods - PostHogExampleWithSPM - PostHogTests + - PostHogSurveyUITests - PostHog/Utils/ReadWriteLock.swift - PostHog/Utils/Reachability.swift - PostHog/Utils/Data+Gzip.swift diff --git a/Makefile b/Makefile index 3a7a8af8dc..c83347db8c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap releaseCocoaPods api apiCheck apiUpdate buildIOS +.PHONY: testSurveyUI build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap releaseCocoaPods api apiCheck apiUpdate buildIOS build: buildSdk buildExamples @@ -105,6 +105,16 @@ testOniOSSimulator: status=$$?; \ scripts/check-ios-test-result.sh "$$status" xcodebuild-ios.log +# Mounted interaction tests use a small test host and the SDK's real survey views. +# Override SURVEY_UI_DESTINATION to select an installed simulator explicitly. +SURVEY_UI_DESTINATION ?= platform=iOS Simulator,name=$$(xcrun simctl list devices available | grep -E '^[[:space:]]*iPhone' | head -1 | sed -E 's/^[[:space:]]*//; s/ \(.*//') +testSurveyUI: + set -o pipefail && xcrun xcodebuild test -project PostHog.xcodeproj -scheme PostHogSurveyUI \ + -destination "$(SURVEY_UI_DESTINATION)" -parallel-testing-enabled NO \ + $(SURVEY_UI_XCODEBUILD_ARGS) | tee survey-ui-tests.log | xcpretty + @grep -qE "Test Case .*SurveyAutoSubmitUITests.* passed" survey-ui-tests.log || { \ + echo "error: no survey UI tests executed."; exit 1; } + testOnMacSimulator: set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination 'platform=macOS' | xcpretty diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 5a37ee4495..c4a85d2fbc 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -612,6 +612,12 @@ F22413BB1C82340F203271A4 /* fixture_survey_question_rating_no_bounds.json in Resources */ = {isa = PBXBuildFile; fileRef = 45D65956292974AA707263FA /* fixture_survey_question_rating_no_bounds.json */; }; F6DADC57034D5B70B502F251 /* PostHogLogsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDAA5E5DBE9861837899D553 /* PostHogLogsConfig.swift */; }; FAAA4C305C550B22060A9905 /* PLCrashReportMachineInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 42CE9EE391A5AE700A1D688F /* PLCrashReportMachineInfo.m */; }; + 007A409E20B7A039A54A276A /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 1172D7DA9CE1DCFC0A9AF21E /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; + 384655E77F5ED30CCF7A9F75 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D52B39DF95AC27A7E9434A44 /* Foundation.framework */; }; + 43466852954C8906BE829586 /* SurveyTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584194DC1C90599E47F37F1F /* SurveyTestApp.swift */; }; + F0B5428D2409438D5FCE2D08 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D52B39DF95AC27A7E9434A44 /* Foundation.framework */; }; + FD65F0216E9FAC83EFF51A04 /* SurveyAutoSubmitUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FEFC64D4286615B0DC16C7E /* SurveyAutoSubmitUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -706,6 +712,20 @@ remoteGlobalIDString = 3AC745B4296D6FE60025C109; remoteInfo = PostHog; }; + 4C62400237F9BF169AECD533 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AC745AC296D6FE60025C109 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3AC745B4296D6FE60025C109; + remoteInfo = PostHog; + }; + EEA4FFB46B196B45C7DABBB0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3AC745AC296D6FE60025C109 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 421B9EBAB6A8074E473FA289; + remoteInfo = PostHogSurveyTestHost; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -775,6 +795,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + D87C6A04EF77CAFDBBED6F6B /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 007A409E20B7A039A54A276A /* PostHog.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -1452,6 +1483,11 @@ F808F6F63C4473F4570FE32B /* PLCrashReport.pb-c.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = "PLCrashReport.pb-c.c"; path = "vendor/PHPLCrashReporter/Source/PLCrashReport.pb-c.c"; sourceTree = ""; }; F9BB47DFE51AFE8F516B8550 /* PLCrashReportExceptionInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportExceptionInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportExceptionInfo.m; sourceTree = ""; }; FB9F9CC224AC280F8A3C55E0 /* PLCrashAsync.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashAsync.c; path = vendor/PHPLCrashReporter/Source/PLCrashAsync.c; sourceTree = ""; }; + 0FEFC64D4286615B0DC16C7E /* SurveyAutoSubmitUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurveyAutoSubmitUITests.swift; sourceTree = ""; }; + 11611BBC98920ED8E56F6C8F /* PostHogSurveyTestHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PostHogSurveyTestHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 584194DC1C90599E47F37F1F /* SurveyTestApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurveyTestApp.swift; sourceTree = ""; }; + AC258B1E8CA7751DBD85B469 /* PostHogSurveyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostHogSurveyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D52B39DF95AC27A7E9434A44 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1515,6 +1551,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2510F5A11767BF3558ABA992 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 384655E77F5ED30CCF7A9F75 /* Foundation.framework in Frameworks */, + 1172D7DA9CE1DCFC0A9AF21E /* PostHog.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A091EB1DFA22A197EEE0F3FE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F0B5428D2409438D5FCE2D08 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1627,6 +1680,7 @@ 3AC745B6296D6FE60025C109 /* Products */, 69261D152AD92D6C00232EC7 /* Frameworks */, DAE8AEC02D9D0E7700A1FE3A /* Recovered References */, + F9E36A6C65E5AE7310C1C450 /* PostHogSurveyUITests */, ); sourceTree = ""; }; @@ -1640,6 +1694,8 @@ 699991852AFE1B37000DCB78 /* PostHogExampleMacOS.app */, 6992AA802AFE51A000087600 /* PostHogExampleTvOS.app */, 69F517F82BAC768100F52C14 /* PostHogExampleStoryboard.app */, + 11611BBC98920ED8E56F6C8F /* PostHogSurveyTestHost.app */, + AC258B1E8CA7751DBD85B469 /* PostHogSurveyUITests.xctest */, ); name = Products; sourceTree = ""; @@ -1804,6 +1860,7 @@ 69261D152AD92D6C00232EC7 /* Frameworks */ = { isa = PBXGroup; children = ( + E50817B754A4E4CAE31331EE /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -2624,6 +2681,33 @@ path = Tracing; sourceTree = ""; }; + 70B33E59D6CB9A70885ED26D /* Host */ = { + isa = PBXGroup; + children = ( + 584194DC1C90599E47F37F1F /* SurveyTestApp.swift */, + ); + name = Host; + path = Host; + sourceTree = ""; + }; + E50817B754A4E4CAE31331EE /* iOS */ = { + isa = PBXGroup; + children = ( + D52B39DF95AC27A7E9434A44 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + F9E36A6C65E5AE7310C1C450 /* PostHogSurveyUITests */ = { + isa = PBXGroup; + children = ( + 70B33E59D6CB9A70885ED26D /* Host */, + 0FEFC64D4286615B0DC16C7E /* SurveyAutoSubmitUITests.swift */, + ); + name = PostHogSurveyUITests; + path = PostHogSurveyUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -2910,6 +2994,43 @@ productReference = 69F517F82BAC768100F52C14 /* PostHogExampleStoryboard.app */; productType = "com.apple.product-type.application"; }; + 08FEA42A531A3BC8D161D747 /* PostHogSurveyUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C79D960C1B261E1E3D63F6C5 /* Build configuration list for PBXNativeTarget "PostHogSurveyUITests" */; + buildPhases = ( + 5986CD91B27F09311B7DE107 /* Sources */, + A091EB1DFA22A197EEE0F3FE /* Frameworks */, + 932E945B4428CE7E9378DD04 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 32644C7024503A84DCA0DE07 /* PBXTargetDependency */, + ); + name = PostHogSurveyUITests; + productName = PostHogSurveyUITests; + productReference = AC258B1E8CA7751DBD85B469 /* PostHogSurveyUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 421B9EBAB6A8074E473FA289 /* PostHogSurveyTestHost */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13ED9993DBB47BA6D4909EBB /* Build configuration list for PBXNativeTarget "PostHogSurveyTestHost" */; + buildPhases = ( + 266BFD23CE72821002416195 /* Sources */, + 2510F5A11767BF3558ABA992 /* Frameworks */, + AE560FF3E09FD1B7870542F6 /* Resources */, + D87C6A04EF77CAFDBBED6F6B /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 417572EB61927AE3EDB509DD /* PBXTargetDependency */, + ); + name = PostHogSurveyTestHost; + productName = PostHogSurveyTestHost; + productReference = 11611BBC98920ED8E56F6C8F /* PostHogSurveyTestHost.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2994,6 +3115,8 @@ 699991842AFE1B37000DCB78 /* PostHogExampleMacOS */, 6992AA7F2AFE51A000087600 /* PostHogExampleTvOS */, 69F517F72BAC768100F52C14 /* PostHogExampleStoryboard */, + 421B9EBAB6A8074E473FA289 /* PostHogSurveyTestHost */, + 08FEA42A531A3BC8D161D747 /* PostHogSurveyUITests */, ); }; /* End PBXProject section */ @@ -3149,6 +3272,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 932E945B4428CE7E9378DD04 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AE560FF3E09FD1B7870542F6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -3600,6 +3737,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 266BFD23CE72821002416195 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 43466852954C8906BE829586 /* SurveyTestApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5986CD91B27F09311B7DE107 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD65F0216E9FAC83EFF51A04 /* SurveyAutoSubmitUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -3638,6 +3791,18 @@ target = 3AC745B4296D6FE60025C109 /* PostHog */; targetProxy = DCB8010C2D8AFFFC00A05A74 /* PBXContainerItemProxy */; }; + 32644C7024503A84DCA0DE07 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = PostHogSurveyTestHost; + target = 421B9EBAB6A8074E473FA289 /* PostHogSurveyTestHost */; + targetProxy = EEA4FFB46B196B45C7DABBB0 /* PBXContainerItemProxy */; + }; + 417572EB61927AE3EDB509DD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = PostHog; + target = 3AC745B4296D6FE60025C109 /* PostHog */; + targetProxy = 4C62400237F9BF169AECD533 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -4762,6 +4927,167 @@ }; name = Testing; }; + 09F8283F06E9FD85A2FAF207 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PostHogSurveyTestHost; + }; + name = Debug; + }; + 7831C7010B59EE63D85A9EFE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyTestHost; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/phlibwebp $(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter $(SRCROOT)/PostHog/PrivateModules/PostHogObjCExceptionSupport"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 78DD251417BE392002B29D13 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyTestHost; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/phlibwebp $(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter $(SRCROOT)/PostHog/PrivateModules/PostHogObjCExceptionSupport"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B9AFCFC2DE5149D473B6CBBA /* Testing */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PostHogSurveyTestHost; + }; + name = Testing; + }; + C69E0DA1CBB516E989E16DB1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = PostHogSurveyTestHost; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + D3A5384136003CB6F5D1E6A5 /* Testing */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogSurveyTestHost; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/phlibwebp $(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter $(SRCROOT)/PostHog/PrivateModules/PostHogObjCExceptionSupport"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Testing; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -4845,6 +5171,26 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 13ED9993DBB47BA6D4909EBB /* Build configuration list for PBXNativeTarget "PostHogSurveyTestHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 78DD251417BE392002B29D13 /* Release */, + 7831C7010B59EE63D85A9EFE /* Debug */, + D3A5384136003CB6F5D1E6A5 /* Testing */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C79D960C1B261E1E3D63F6C5 /* Build configuration list for PBXNativeTarget "PostHogSurveyUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C69E0DA1CBB516E989E16DB1 /* Release */, + 09F8283F06E9FD85A2FAF207 /* Debug */, + B9AFCFC2DE5149D473B6CBBA /* Testing */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogSurveyUI.xcscheme b/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogSurveyUI.xcscheme new file mode 100644 index 0000000000..9f85a63377 --- /dev/null +++ b/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogSurveyUI.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PostHogSurveyUITests/Host/SurveyTestApp.swift b/PostHogSurveyUITests/Host/SurveyTestApp.swift new file mode 100644 index 0000000000..895f29184f --- /dev/null +++ b/PostHogSurveyUITests/Host/SurveyTestApp.swift @@ -0,0 +1,65 @@ +@testable import PostHog +import SwiftUI + +@main +struct SurveyTestApp: App { + @StateObject private var fixture = SurveyFixture() + + var body: some Scene { + WindowGroup { + VStack { + Text(fixture.answers.isEmpty ? "none" : fixture.answers.joined(separator: "|")) + .accessibilityIdentifier("answers") + SurveySheet(displayManager: fixture.controller, fallbackSurvey: fixture.survey) + } + } + } +} + +// Fixtures and the response recorder live in the test host; all controls, state and +// transitions under test belong to the SDK's SurveySheet and SurveyDisplayController. +private final class SurveyFixture: ObservableObject { + let controller = SurveyDisplayController() + let survey: PostHogDisplaySurvey + @Published var answers: [String] = [] + + init() { + let environment = ProcessInfo.processInfo.environment + let kind = environment["SURVEY_KIND"] ?? "number" + let flag = environment["SURVEY_SKIP"] ?? "true" + let type = kind == "number" || kind == "emoji" ? "rating" : kind == "multiple" ? "multiple_choice" : "single_choice" + var first: [String: Any] = [ + "id": "first", "question": "First question", "type": type, + "display": kind, "scale": 5, "choices": ["First", "Second", "Other"], + "hasOpenChoice": kind == "open", "buttonText": "Continue", + "optional": environment["SURVEY_OPTIONAL"] == "true", + ] + if flag != "missing" { + first["skipSubmitButton"] = flag == "true" + } + var second = first + second["id"] = "second" + second["question"] = "Second question" + second["skipSubmitButton"] = false + second["optional"] = false + var skipped = first + skipped["id"] = "skipped" + skipped["question"] = "Skipped question" + let questions = [first, skipped, second].map { json -> PostHogDisplaySurveyQuestion in + // Invalid fixtures should fail launch rather than silently exercise a different UI. + let data = try! JSONSerialization.data(withJSONObject: json) + return try! PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: data).toDisplayQuestion()! + } + survey = PostHogDisplaySurvey( + id: "ui-test", name: "UI test", questions: questions, + appearance: nil, startDate: nil, endDate: nil + ) + controller.onSurveyResponse = { [weak self] _, index, response in + let value = response.ratingValue.map(String.init) + ?? response.selectedOptions?.joined(separator: ",") ?? "nil" + self?.answers.append("\(index):\(value)") + return PostHogNextSurveyQuestion(questionIndex: 2, isSurveyCompleted: index == 2) + } + controller.showSurvey(survey) + } +} diff --git a/PostHogSurveyUITests/README.md b/PostHogSurveyUITests/README.md new file mode 100644 index 0000000000..21662df291 --- /dev/null +++ b/PostHogSurveyUITests/README.md @@ -0,0 +1,18 @@ +# Survey interaction tests + +Run `make testSurveyUI` with an installed iPhone simulator. To select a device: + +```sh +make testSurveyUI SURVEY_UI_DESTINATION='platform=iOS Simulator,name=iPhone 17 Pro' +``` + +The shared `PostHogSurveyUI` scheme builds a test-only app and an XCUITest bundle. +The app uses `@testable import PostHog` to decode fixtures and mount the real +`SurveySheet` with `SurveyDisplayController`. Only fixture setup and a callback +recorder belong to the host; selections and navigation execute SDK code. + +Tests tap the actual controls and inspect the received answer sequence, current +question, and submit button. They cover numeric/emoji/single-choice auto-submit, +branching and state reset between same-type questions, false/missing flags, open-choice text, +multiple choice, and optional manual skipping. XCUITest requires XCTest, so these +interaction tests use XCTest while the SDK's unit tests continue using Swift Testing. diff --git a/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift b/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift new file mode 100644 index 0000000000..82a3e78e76 --- /dev/null +++ b/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift @@ -0,0 +1,136 @@ +import XCTest + +final class SurveyAutoSubmitUITests: XCTestCase { + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + } + + override func tearDownWithError() throws { + app.terminate() + app = nil + } + + func testNumberAutoSubmitAndConsecutiveQuestionReset() { + assertAutoSubmit(kind: "number", answer: "4") + } + + func testEmojiAutoSubmitAndConsecutiveQuestionReset() { + assertAutoSubmit(kind: "emoji", answer: "4") + } + + func testSingleChoiceAutoSubmitAndConsecutiveQuestionReset() { + assertAutoSubmit(kind: "single", answer: "First") + } + + func testFalseAndMissingFlagRequireExplicitSubmission() { + for kind in ["number", "emoji", "single"] { + for flag in ["false", "missing"] { + launch(kind: kind, flag: flag) + XCTAssertTrue(app.buttons["Continue"].exists) + XCTAssertFalse(app.buttons["Continue"].isEnabled) + select(kind: kind) + XCTAssertTrue(app.staticTexts["First question"].exists) + assertAnswers("") + XCTAssertTrue(app.buttons["Continue"].isEnabled) + app.buttons["Continue"].tap() + assertQuestion("Second question") + assertAnswers(kind == "single" ? "0:First" : "0:4") + app.terminate() + } + } + } + + func testOpenChoiceRequiresTextAndExplicitSubmission() { + launch(kind: "open") + app.buttons["First"].tap() + assertAnswers("") + XCTAssertTrue(app.buttons["Continue"].isEnabled) + app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Other:")).firstMatch.tap() + XCTAssertFalse(app.buttons["Continue"].isEnabled) + let input = app.textFields.firstMatch + XCTAssertTrue(input.waitForExistence(timeout: 5)) + input.tap() + input.typeText("Custom answer") + assertAnswers("") + XCTAssertTrue(app.buttons["Continue"].isEnabled) + app.buttons["Continue"].tap() + assertQuestion("Second question") + assertAnswers("0:Custom answer") + } + + func testMultipleChoiceKeepsExplicitSubmission() { + launch(kind: "multiple") + app.buttons["First"].tap() + app.buttons["Second"].tap() + assertQuestion("First question") + assertAnswers("") + XCTAssertTrue(app.buttons["Continue"].isEnabled) + app.buttons["Continue"].tap() + assertQuestion("Second question") + assertAnswers("0:First,Second") + } + + func testOptionalQuestionCanStillSkipWithExplicitSubmission() { + launch(kind: "number", flag: "false", optional: true) + XCTAssertTrue(app.buttons["Continue"].isEnabled) + app.buttons["Continue"].tap() + assertQuestion("Second question") + assertAnswers("0:nil") + } + + private func assertAutoSubmit(kind: String, answer: String) { + launch(kind: kind) + XCTAssertFalse(app.buttons["Continue"].exists) + select(kind: kind) + assertQuestion("Second question") + assertAnswers("0:\(answer)") + XCTAssertTrue(app.buttons["Continue"].exists) + XCTAssertFalse(app.buttons["Continue"].isEnabled, "The next question must start without the previous selection") + select(kind: kind) + XCTAssertTrue(app.buttons["Continue"].isEnabled) + assertAnswers("0:\(answer)") + app.buttons["Continue"].tap() + XCTAssertTrue(app.staticTexts["Thank you for your feedback!"].waitForExistence(timeout: 5)) + assertAnswers("0:\(answer)|2:\(answer)") + } + + private func launch(kind: String, flag: String = "true", optional: Bool = false) { + app.launchEnvironment = [ + "SURVEY_KIND": kind, "SURVEY_SKIP": flag, + "SURVEY_OPTIONAL": String(optional), + ] + app.launch() + assertQuestion("First question") + assertAnswers("") + } + + private func select(kind: String) { + if kind == "single" { + app.buttons["First"].tap() + } else if kind == "emoji" { + // Emoji artwork has no text label. Locate the five actual rating buttons + // by their control dimensions, then tap the fourth in display order. + let ratings = app.buttons.allElementsBoundByIndex.filter { + abs($0.frame.width - 48) < 1 && abs($0.frame.height - 48) < 1 + }.sorted { $0.frame.minX < $1.frame.minX } + XCTAssertEqual(ratings.count, 5) + ratings[3].tap() + } else { + app.buttons["4"].tap() + } + } + + private func assertQuestion(_ title: String) { + XCTAssertTrue(app.staticTexts[title].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["Skipped question"].exists) + } + + private func assertAnswers(_ expected: String) { + let predicate = NSPredicate(format: "label == %@", expected.isEmpty ? "none" : expected) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: app.staticTexts["answers"]) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed) + } +} From 17d1d8b769b09fc1da4a4149af3269274c8f86f4 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 11:21:22 -0300 Subject: [PATCH 5/6] test: await identify upload before releasing the identity fixture The following custom-distinct-ID test could receive the preceding test's asynchronous identify upload after its batch recorder was reset. Wait for and assert that upload before the preceding fixture ends. Validation: all 25 identity tests and the full SPM suite passed. --- PostHogTests/PostHogIdentityTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/PostHogTests/PostHogIdentityTests.swift b/PostHogTests/PostHogIdentityTests.swift index 887a57d40a..611323dbbf 100644 --- a/PostHogTests/PostHogIdentityTests.swift +++ b/PostHogTests/PostHogIdentityTests.swift @@ -121,6 +121,11 @@ class PostHogIdentityTests { #expect(sut.getDistinctId() == "newDistinctId") #expect(sut.getAnonymousId() == distId) + + // Finish this test's upload before the next test installs its batch recorder. + let events = try await getServerEvents(server) + #expect(events.map(\.event) == ["$identify"]) + #expect(events.first?.distinctId == "newDistinctId") } @Test("captures the capture event with a custom distinctId") From 786fee54c5741bfa52d960f55260c6ef25ff7dfd Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 12:30:28 -0300 Subject: [PATCH 6/6] test(surveys): identify the primary action in UI tests Give the survey primary action a stable accessibility identifier and query that identifier in the mounted tests. Label-only Continue queries can match another control after the keyboard opens, which failed the open choice interaction in CI. Verification: make testSurveyUI (7 tests), make format, make lint, make apiCheck, git diff checks and CodeScene safeguard. No public API snapshot changes; no CodeScene findings. --- PostHog/Surveys/BottomSection.swift | 1 + .../SurveyAutoSubmitUITests.swift | 35 ++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/PostHog/Surveys/BottomSection.swift b/PostHog/Surveys/BottomSection.swift index 646d2fd2b4..c12f647db0 100644 --- a/PostHog/Surveys/BottomSection.swift +++ b/PostHog/Surveys/BottomSection.swift @@ -16,6 +16,7 @@ var body: some View { Button(label, action: action) .buttonStyle(SurveyButtonStyle()) + .accessibilityIdentifier("posthog.survey.primary-action") .padding(.bottom, 16) } } diff --git a/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift b/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift index 82a3e78e76..a642652a74 100644 --- a/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift +++ b/PostHogSurveyUITests/SurveyAutoSubmitUITests.swift @@ -2,6 +2,7 @@ import XCTest final class SurveyAutoSubmitUITests: XCTestCase { private var app: XCUIApplication! + private var continueButton: XCUIElement { app.buttons["posthog.survey.primary-action"] } override func setUpWithError() throws { continueAfterFailure = false @@ -29,13 +30,13 @@ final class SurveyAutoSubmitUITests: XCTestCase { for kind in ["number", "emoji", "single"] { for flag in ["false", "missing"] { launch(kind: kind, flag: flag) - XCTAssertTrue(app.buttons["Continue"].exists) - XCTAssertFalse(app.buttons["Continue"].isEnabled) + XCTAssertTrue(continueButton.exists) + XCTAssertFalse(continueButton.isEnabled) select(kind: kind) XCTAssertTrue(app.staticTexts["First question"].exists) assertAnswers("") - XCTAssertTrue(app.buttons["Continue"].isEnabled) - app.buttons["Continue"].tap() + XCTAssertTrue(continueButton.isEnabled) + continueButton.tap() assertQuestion("Second question") assertAnswers(kind == "single" ? "0:First" : "0:4") app.terminate() @@ -47,16 +48,16 @@ final class SurveyAutoSubmitUITests: XCTestCase { launch(kind: "open") app.buttons["First"].tap() assertAnswers("") - XCTAssertTrue(app.buttons["Continue"].isEnabled) + XCTAssertTrue(continueButton.isEnabled) app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Other:")).firstMatch.tap() - XCTAssertFalse(app.buttons["Continue"].isEnabled) + XCTAssertFalse(continueButton.isEnabled) let input = app.textFields.firstMatch XCTAssertTrue(input.waitForExistence(timeout: 5)) input.tap() input.typeText("Custom answer") assertAnswers("") - XCTAssertTrue(app.buttons["Continue"].isEnabled) - app.buttons["Continue"].tap() + XCTAssertTrue(continueButton.isEnabled) + continueButton.tap() assertQuestion("Second question") assertAnswers("0:Custom answer") } @@ -67,32 +68,32 @@ final class SurveyAutoSubmitUITests: XCTestCase { app.buttons["Second"].tap() assertQuestion("First question") assertAnswers("") - XCTAssertTrue(app.buttons["Continue"].isEnabled) - app.buttons["Continue"].tap() + XCTAssertTrue(continueButton.isEnabled) + continueButton.tap() assertQuestion("Second question") assertAnswers("0:First,Second") } func testOptionalQuestionCanStillSkipWithExplicitSubmission() { launch(kind: "number", flag: "false", optional: true) - XCTAssertTrue(app.buttons["Continue"].isEnabled) - app.buttons["Continue"].tap() + XCTAssertTrue(continueButton.isEnabled) + continueButton.tap() assertQuestion("Second question") assertAnswers("0:nil") } private func assertAutoSubmit(kind: String, answer: String) { launch(kind: kind) - XCTAssertFalse(app.buttons["Continue"].exists) + XCTAssertFalse(continueButton.exists) select(kind: kind) assertQuestion("Second question") assertAnswers("0:\(answer)") - XCTAssertTrue(app.buttons["Continue"].exists) - XCTAssertFalse(app.buttons["Continue"].isEnabled, "The next question must start without the previous selection") + XCTAssertTrue(continueButton.exists) + XCTAssertFalse(continueButton.isEnabled, "The next question must start without the previous selection") select(kind: kind) - XCTAssertTrue(app.buttons["Continue"].isEnabled) + XCTAssertTrue(continueButton.isEnabled) assertAnswers("0:\(answer)") - app.buttons["Continue"].tap() + continueButton.tap() XCTAssertTrue(app.staticTexts["Thank you for your feedback!"].waitForExistence(timeout: 5)) assertAnswers("0:\(answer)|2:\(answer)") }