Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
437a51d
test(bucketing): import cross-SDK anchored golden vectors (v12 contract)
abbaseya Jul 4, 2026
0ff796f
test(bucketing): anchored layout v12 — failing tests + stub (RED)
abbaseya Jul 4, 2026
844c347
feat!: anchored bucketing layout for traffic ramping (contract v12)
abbaseya Jul 4, 2026
979c019
test(bucketing): align anchored test doc comments with GREEN implemen…
abbaseya Jul 4, 2026
c387ad7
refactor(bucketing): simplify anchored active check and use resolved …
abbaseya Jul 4, 2026
8c1dddf
Merge pull request #41 from convertcom/feat/anchored-bucketing-layout
JosephSamirL Jul 6, 2026
be3619f
wf(wf-iossdk-qs02): [IOS-1] debugToken config + transport + cache + r…
abbaseya Jul 8, 2026
2f80ca7
wf(wf-iossdk-qs02): [IOS-1] debugToken config + transport + cache + r…
abbaseya Jul 8, 2026
68037ff
wf(wf-iossdk-qs02): [IOS-2] PreviewParam.parse pure helper — tests (RED)
abbaseya Jul 8, 2026
92b3199
wf(wf-iossdk-qs02): [IOS-2] PreviewParam.parse pure helper — implemen…
abbaseya Jul 8, 2026
6080bcd
wf(wf-iossdk-qs02): [IOS-3] forced-variation decision primitive — tes…
abbaseya Jul 8, 2026
2fc2ac3
wf(wf-iossdk-qs02): [IOS-3] forced-variation decision primitive — imp…
abbaseya Jul 8, 2026
673ec19
wf(wf-iossdk-qs02): [IOS-4] ?exp= config fetch + 60s memo — tests (RED)
abbaseya Jul 8, 2026
f9ec7c0
wf(wf-iossdk-qs02): [IOS-4] ?exp= config fetch + 60s memo — implement…
abbaseya Jul 8, 2026
0ea9662
wf(wf-iossdk-qs02): [IOS-5] setPreview + PreviewState wiring + isolat…
abbaseya Jul 8, 2026
3c2b962
wf(wf-iossdk-qs02): [IOS-5] setPreview + PreviewState wiring + isolat…
abbaseya Jul 8, 2026
fd97841
wf(wf-iossdk-qs02): [IOS-6] zero-trace hardening — tests (RED)
abbaseya Jul 8, 2026
be866ed
wf(wf-iossdk-qs02): [IOS-6] zero-trace hardening — implementation (GR…
abbaseya Jul 8, 2026
fc0c5d1
wf(wf-iossdk-qs02): [IOS-fix] gate segment-setter visitor-state persi…
abbaseya Jul 8, 2026
1adb68e
wf(wf-iossdk-qs02): [IOS-fix] gate segment-setter visitor-state persi…
abbaseya Jul 8, 2026
bb51ed6
wf(wf-iossdk-qs02): [IOS-fix2] gate feature-path zero-trace — tests (…
abbaseya Jul 8, 2026
7f73a06
wf(wf-iossdk-qs02): [IOS-fix2] gate feature-path zero-trace — impleme…
abbaseya Jul 8, 2026
50815d9
wf(wf-iossdk-qs02): [IOS-fix3] review R1 — single isPreviewActive rea…
abbaseya Jul 8, 2026
0ca75fe
fix(preview): suppress .bucketing observer event under preview + revi…
abbaseya Jul 15, 2026
b1560d7
wf(wf-iossdk-qs03): [IOS-1] ProjectConfig audience decode-survival — …
abbaseya Jul 15, 2026
e1a1841
wf(wf-iossdk-qs03): [IOS-1] ProjectConfig audience decode-survival — …
abbaseya Jul 15, 2026
893a9a7
wf(wf-iossdk-qs03): [IOS-2] rule-engine core — tests (RED)
abbaseya Jul 15, 2026
6cfb81c
wf(wf-iossdk-qs03): [IOS-2] rule-engine core — implementation (GREEN)
abbaseya Jul 15, 2026
f754c49
wf(wf-iossdk-qs03): [IOS-3] ExperienceManager end-to-end exclusion — …
abbaseya Jul 15, 2026
7d1c64e
wf(wf-iossdk-qs03): [IOS-3] ExperienceManager end-to-end exclusion — …
abbaseya Jul 15, 2026
4611bef
Merge pull request #42 from convertcom/feat/experiment-preview
JosephSamirL Jul 15, 2026
92e6126
wf(wf-iossdk-qs03): [IOS-2] AC7 lock — key-value family on JSON-senti…
abbaseya Jul 15, 2026
0cc6405
wf(wf-iossdk-qs03): [IOS-fix] JSON-sentinel fail-closed allowlist — t…
abbaseya Jul 15, 2026
f5870e2
wf(wf-iossdk-qs03): [IOS-fix] JSON-sentinel fail-closed allowlist — i…
abbaseya Jul 15, 2026
d01d309
wf(wf-iossdk-qs03): [IOS-fix2] correct stale comment on regression ve…
abbaseya Jul 16, 2026
c9ed810
Merge remote-tracking branch 'origin/feat/fullstack-v12' into feat/mu…
abbaseya Jul 16, 2026
9850ce6
wf(wf-iossdk-qs03): [IOS-fix3] rename JSON-flatten test type <=40 cha…
abbaseya Jul 16, 2026
62e5082
wf(wf-iossdk-qs03): [IOS-4] JS #416 parity re-architecture — tests (RED)
abbaseya Jul 16, 2026
827836f
wf(wf-iossdk-qs03): [IOS-4] JS #416 parity re-architecture — implemen…
abbaseya Jul 16, 2026
1cc3c56
Merge pull request #43 from convertcom/feat/mutual-exclusion
JosephSamirL Jul 16, 2026
662d82d
feat(demo): wire experiment-preview deep links into the demo app (qs-03)
abbaseya Jul 18, 2026
bee1a4a
fix(bucketing,preview): packed status filter + clear preview on failure
abbaseya Jul 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
EC9FC27BB45ECFF34FF06369 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = "TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */; };
DEC0DE000000000000000001 /* demo-config.json in Resources */ = {isa = PBXBuildFile; fileRef = DEC0DE000000000000000002 /* demo-config.json */; };
FF6DFE9D2E966A8221A5DCE3 /* ConfigState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26FB3ECD39557B4BD82B372 /* ConfigState.swift */; };
DEC0DE000000000000000004 /* DemoViewModel+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -61,6 +62,8 @@
"TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
DEC0DE000000000000000002 /* demo-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "demo-config.json"; sourceTree = "<group>"; };
"TEMP_CA83CA2C-B089-4A16-B89C-7E997C2ED50B" /* ConvertSwiftSDKDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertSwiftSDKDemoApp.swift; sourceTree = "<group>"; };
DEC0DE000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DemoViewModel+Preview.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -141,6 +144,7 @@
32A7AB9721D664AE01FD4F2F /* DemoViewModel+Config.swift */,
1DF1D7B4E7C17D5D60315AD8 /* DemoViewModel+Conversions.swift */,
0F070E645EA5862EAF753FDF /* DemoViewModel+Inspector.swift */,
DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */,
532237ECDD6A745AABAFEA65 /* InspectorEvent.swift */,
);
path = Model;
Expand All @@ -164,6 +168,7 @@
children = (
"TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */,
DEC0DE000000000000000002 /* demo-config.json */,
DEC0DE000000000000000003 /* Info.plist */,
"TEMP_1BF71A44-8E85-4D7F-A9EB-CE4EF34828DF" /* ContentView.swift */,
"TEMP_CA83CA2C-B089-4A16-B89C-7E997C2ED50B" /* ConvertSwiftSDKDemoApp.swift */,
2BB4597078C45D14D92BC818 /* Tabs */,
Expand Down Expand Up @@ -259,6 +264,7 @@
4C81DFDB24B0026CCFCCA5D7 /* DemoViewModel+Config.swift in Sources */,
9BBEC7FE8D8B26F102D3E669 /* DemoViewModel+Conversions.swift in Sources */,
9C4E5395614537BF82D4DB47 /* DemoViewModel+Inspector.swift in Sources */,
DEC0DE000000000000000004 /* DemoViewModel+Preview.swift in Sources */,
5ABB54AE4D3E168525C35056 /* DemoViewModel.swift in Sources */,
0ADAFCE3988C3032083647DA /* EmptyStateView.swift in Sources */,
EA3FB463D99BCFED14D7199E /* EventInspectorSheet.swift in Sources */,
Expand Down Expand Up @@ -403,9 +409,8 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = BrandAccent;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
Expand All @@ -427,9 +432,8 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = BrandAccent;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
Expand Down
9 changes: 9 additions & 0 deletions Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ struct ConvertSwiftSDKDemoApp: App {
await viewModel.startEventInspector()
await viewModel.start()
}
// Experiment-preview deep links (qs-02/qs-03 host-app wiring, `Model/
// DemoViewModel+Preview.swift`). The demo registers the `convertdemo` URL
// scheme (see Info.plist / README "Testing experiment-preview deep links");
// SwiftUI routes any open-URL event here regardless of which tab is active.
// `applyPreviewLink` is fully inert on a missing/malformed `convert_preview`
// query item, so a stray or malformed URL never corrupts app state.
.onOpenURL { url in
Task { await viewModel.applyPreviewLink(url) }
}
// Tear the Event Inspector subscription down when the app moves to
// the background — the correct teardown point for the demo. The
// `.task` above does NOT re-fire on return to foreground, and this
Expand Down
50 changes: 50 additions & 0 deletions Demo/ConvertSwiftSDKDemo/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<!--
Experiment-preview deep links (qs-02). The host app owns link registration; this
scheme lets the demo receive convert_preview links locally. See README "Testing
experiment-preview deep links". Trigger in the Simulator with:
xcrun simctl openurl booted "convertdemo://preview?convert_preview=<expId>.<varId>"
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.convert.ConvertSwiftSDKDemo.preview</string>
<key>CFBundleURLSchemes</key>
<array>
<string>convertdemo</string>
</array>
</dict>
</array>
</dict>
</plist>
70 changes: 70 additions & 0 deletions Demo/ConvertSwiftSDKDemo/Model/DemoViewModel+Preview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import ConvertSwiftSDK
import Foundation
import os

// MARK: - Experiment preview deep links (qs-02 / qs-03 host-app wiring)

// This extension is the host-app-owned deep-link wiring the qs-02/qs-03 quick-spec explicitly
// scopes OUT of the SDK ("No link registration inside the SDK — host-app concern"). It receives
// whatever URL SwiftUI's `.onOpenURL` (wired in `ConvertSwiftSDKDemoApp.swift`) hands it, and is
// the ONLY place in the demo that calls the pure `PreviewParam.parse` helper and
// `ConvertContext.setPreview(experienceId:variationId:)`. See the README "Testing
// experiment-preview deep links" section for the end-to-end `simctl openurl` test.
//
// Logging: the demo has no existing print/os_log convention (verified — no other file in
// `Demo/ConvertSwiftSDKDemo` logs anything), so this introduces `os.Logger` (unified logging,
// iOS 14+, safe on the iOS 15 floor) scoped to its own subsystem/category so the applied/ignored
// outcome is independently verifiable via `xcrun simctl spawn booted log stream` without needing
// Xcode attached to the process.
extension DemoViewModel {

/// The unified-logging handle for preview-link diagnostics.
///
/// A dedicated subsystem (the demo's bundle id) + category (`"preview-link"`) so a log
/// stream can filter to exactly these lines; see the README for the exact `log stream`
/// predicate.
private static let previewLogger = Logger(subsystem: "com.convert.ConvertSwiftSDKDemo", category: "preview-link")

/// Applies an incoming experiment-preview deep link.
///
/// Wired from `ConvertSwiftSDKDemoApp.swift`'s `.onOpenURL`. Extracts the `convert_preview`
/// query item, parses it with the SDK's pure ``PreviewParam/parse(_:)`` helper, and — on a
/// successful parse — forces that variation via
/// ``ConvertContext/setPreview(experienceId:variationId:)`` on the sticky ``context``, then
/// re-runs every experience so the forced result appears on the Experiences screen exactly
/// like any other run.
///
/// Fully inert on a missing or malformed `convert_preview` value: logs the reason and returns
/// without touching ``context``, ``resultCards``, or any other published state — a malformed
/// or absent link can never corrupt the demo's decisioning state.
///
/// `@MainActor` (inherited): `URLComponents` construction and ``PreviewParam/parse(_:)`` are
/// synchronous; the two SDK calls (`setPreview`, ``runExperiences()``) `await` without
/// blocking the main actor.
func applyPreviewLink(_ url: URL) async {
guard
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let rawValue = components.queryItems?.first(where: { $0.name == "convert_preview" })?.value
else {
Self.previewLogger.notice("[preview-link] ignored: no convert_preview query item")
return
}

guard let parsed = PreviewParam.parse(rawValue) else {
Self.previewLogger.notice(
"[preview-link] ignored: malformed convert_preview value \(rawValue, privacy: .public)"
)
return
}

await context.setPreview(experienceId: parsed.experienceId, variationId: parsed.variationId)
// Built as a plain `String` first (rather than one long `Logger.notice` string
// interpolation) because `OSLogMessage` interpolations cannot be split across a `+`
// concatenation — the whole message must be a single literal. Wrapping the finished
// string in one `\(…, privacy: .public)` interpolation keeps the log line under the
// line-length limit without losing the per-value privacy annotation semantics.
let appliedMessage = "[preview-link] applied exp=\(parsed.experienceId) var=\(parsed.variationId)"
Self.previewLogger.notice("\(appliedMessage, privacy: .public)")
await runExperiences()
}
}
72 changes: 72 additions & 0 deletions Demo/ConvertSwiftSDKDemo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ConvertSwiftSDKDemo

A SwiftUI demo app for the Convert FullStack iOS SDK (Epic 7). It runs OFFLINE by default,
bucketing against the bundled `demo-config.json` (a real FS-Test-Proj staging config with
audience/location gates cleared so the run screens bucket deterministically with no network).

## Testing experiment-preview deep links

The demo registers the custom URL scheme `convertdemo://` (see `Info.plist`'s
`CFBundleURLTypes`) so it can receive experiment-preview deep links exactly like a host app
would wire the SDK's qs-02/qs-03 preview surface (`PreviewParam.parse` +
`ConvertContext.setPreview(experienceId:variationId:)`). Link registration and this deep-link
route are a **host-app concern** — the SDK itself does not register any link (explicit
non-goal) — so this wiring lives entirely in the demo:

- `ConvertSwiftSDKDemoApp.swift` — `.onOpenURL { url in Task { await viewModel.applyPreviewLink(url) } }`
- `Model/DemoViewModel+Preview.swift` — `applyPreviewLink(_:)` parses the link and calls into the SDK

### Canonical link format

```
convertdemo://preview?convert_preview={experienceId}.{variationId}
```

`convert_preview` is the same dot-separated `{experienceId}.{variationId}` param the SDK's
`PreviewParam.parse` helper expects — two non-empty, all-digit components separated by a
single `.`. Any other shape (missing param, non-numeric ids, wrong separator) is ignored: the
demo logs the reason and leaves the current context/results untouched.

### Concrete example (bundled demo-config IDs)

The bundled `demo-config.json` carries experience `test-experience-ab-fullstack-4`
(id `100349071`) with two variations: `original` (id `1003180877`) and `variation-1`
(id `1003180878`). To force the second variation:

```
xcrun simctl openurl booted "convertdemo://preview?convert_preview=100349071.1003180878"
```

**Expected result:** the app forces `test-experience-ab-fullstack-4` to `variation-1` and the
Experiences screen's result cards refresh — the top card reads
`test-experience-ab-fullstack-4` / `Variation variation-1`, regardless of whatever the
bucketing hash would otherwise have picked for the current visitor.

### Confirming zero-trace via the Event Inspector

Preview is a full decision bypass with **zero trace**: no bucketing enqueue, no sticky-decision
write, no conversion tracking to the source. Open the demo's Event Inspector (the toolbar
button present on every tab) and check the **Events** segment: applying the preview link above
must NOT add a new `.bucketing` row for `test-experience-ab-fullstack-4`. (The Inspector's
**Logs** segment is a placeholder — Story 7.2b, not yet wired to a live log stream — so use
`log stream`, below, for the preview-link diagnostic lines themselves.)

To see the demo's own `[preview-link] applied …` / `[preview-link] ignored: …` diagnostic line
(emitted via unified logging, not the Event Inspector), stream the simulator log in a separate
terminal before running the `simctl openurl` command:

```
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.convert.ConvertSwiftSDKDemo" AND category == "preview-link"'
```

### Offline vs. live

- **In-config experiences preview fully offline.** Any experience already present in
`demo-config.json` (like `test-experience-ab-fullstack-4` above) previews with no network,
since the SDK already holds its `ConfigExperience` and can force the named variation directly.
- **A draft / not-yet-loaded experience needs a live serving host.** If the previewed
`experienceId` is absent from the currently-held config snapshot, the SDK falls back to the
`?exp=`-scoped `ConfigFetchService.fetchExperienceConfig(experienceId:)` fetch, which requires
network access to a real Convert serving host. Point the demo at a live environment via
`ConvertConfiguration`'s `apiConfigEndpoint` (rather than the bundled offline `demo-config.json`
path) before previewing an experience that isn't already in the bundled config.
20 changes: 17 additions & 3 deletions Demo/ConvertSwiftSDKDemo/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.convert.ConvertSwiftSDKDemo
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: 1
SWIFT_VERSION: "6.0"
Expand All @@ -36,6 +33,23 @@ targets:
# .tint(ConvertTheme.accent) uses), so there is no separate AccentColor
# asset to drift out of sync (AC2 — one mode-resolving accent).
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: BrandAccent
# A real Info.plist (rather than GENERATE_INFOPLIST_FILE) is required from here on:
# experiment-preview deep links (qs-02/qs-03 host-app wiring) register the `convertdemo`
# URL scheme via CFBundleURLTypes, which the INFOPLIST_KEY_* build-setting shorthand cannot
# express. XcodeGen writes this file on every `generate` run, so its properties must mirror
# Info.plist exactly; see that file's own header comment for the `simctl openurl` test command.
info:
path: Info.plist
properties:
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
LSRequiresIPhoneOS: true
CFBundleURLTypes:
- CFBundleTypeRole: Editor
CFBundleURLName: com.convert.ConvertSwiftSDKDemo.preview
CFBundleURLSchemes:
- convertdemo
dependencies:
- package: ConvertSwiftSDK
product: ConvertSwiftSDK
Loading
Loading