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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Product Page Optimization tests** — `asc experiments list|get|create|update|start|stop|delete`, `asc experiment-treatments list|create|update|delete`, and `asc experiment-treatment-localizations list|create|delete` manage App Store product page A/B tests (ASC API `appStoreVersionExperiments` v2). A test is app-scoped; `create` takes `--name`, `--platform` and `--traffic-proportion 1-100`; `start`/`stop` map to `PATCH { started: true|false }`. New `AppStoreVersionExperiment` (+ `AppStoreVersionExperimentState` with `isEditable`/`isPendingReview`/`isApproved`/`isFinished`, and `isRunning`/`canStart` on the model), `ExperimentTreatment` and `ExperimentTreatmentLocalization` domain types in `Domain/Apps/Experiments/`, backed by `ExperimentRepository`. Affordances are state-aware: `createTreatment`/`update`/`delete` only while editable, `start` only when approved and unstarted, `stop` only while running. `App` gains a `listExperiments` affordance for discovery. REST equivalents under `/api/v1/apps/:appId/experiments`, `/api/v1/experiments/:id[/start|/stop|/experiment-treatments]`, `/api/v1/experiment-treatments/:id[/experiment-treatment-localizations]` and `/api/v1/experiment-treatment-localizations/:id` via `ExperimentsController`. See `docs/features/product-page-optimization.md`.

---

## [0.18.2] - 2026-07-16
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ App → CiProduct (XcodeCloud) → CiWorkflow → CiBuildRun
AppStoreVersion → VersionReadiness
AppStoreVersion → AppStoreReviewDetail
CodeSigning: BundleID → Profile
App → AppStoreVersionExperiment → ExperimentTreatment → ExperimentTreatmentLocalization
App → PerformanceMetric (via perfPowerMetrics)
Build → PerformanceMetric (via perfPowerMetrics)
Build → DiagnosticSignatureInfo → DiagnosticLogEntry
Expand All @@ -140,6 +141,8 @@ Domain/
│ │ CustomerReviewRepository
│ ├── Builds/ → Build, BuildUpload, BetaBuildLocalization,
│ │ BuildRepository, BuildUploadRepository, BetaBuildLocalizationRepository
│ ├── Experiments/ → AppStoreVersionExperiment, AppStoreVersionExperimentState,
│ │ ExperimentTreatment, ExperimentTreatmentLocalization, ExperimentRepository
│ ├── Pricing/ → PricingRepository
│ ├── TestFlight/ → BetaGroup, BetaTester, TestFlightRepository
│ └── Performance/ → PerformanceMetric, PerformanceMetricCategory, DiagnosticSignatureInfo,
Expand Down Expand Up @@ -244,7 +247,7 @@ After every code change — new feature, improvement, or bug fix — update all
Key skills to keep in sync:
- `implement-feature/SKILL.md` — workflow + checklist
- `asc-cli/references/commands.md` — command reference
- Feature-specific skills (`asc-testflight`, `asc-beta-review`, `asc-builds-upload`, `asc-code-signing`, `asc-check-readiness`, `asc-app-previews`, `asc-app-shots`, `asc-review-detail`, `asc-plugins`, etc.)
- Feature-specific skills (`asc-testflight`, `asc-beta-review`, `asc-builds-upload`, `asc-code-signing`, `asc-check-readiness`, `asc-app-previews`, `asc-app-shots`, `asc-review-detail`, `asc-plugins`, `asc-experiments`, etc.)

**`CLAUDE.md`** — update when architecture patterns, file locations, or design rules change.

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Now `asc iap list --app-id <id>` enriches each IAP with the right submission aff
| **Authentication** | Multi-account credential management; named accounts, active-account switching |
| **Project Init** | `asc init` pins app context to `.asc/project.json`; auto-detects from `.xcodeproj` |
| **Customer Reviews** | Read customer reviews, respond to feedback, and manage review responses |
| **Product Page Optimization** | Create A/B tests of your product page (`experiments`), add up to three treatments with alternate icons, choose locales, start/stop the test |
| **App Clips** | Manage App Clips, default experiences, and locale-specific card content |
| **Game Center** | Manage achievements and leaderboards for your game |
| **Plugins** | Install executable plugins in `~/.asc/plugins/` for custom event handlers |
Expand Down Expand Up @@ -361,6 +362,9 @@ asc subscriptions create --group-id <gid> --name "Monthly" --product-id "com.app
asc subscriptions prices set --subscription-id <sub> --territory USA --price-point-id <pp>

asc promoted-purchases create --app-id <id> --iap-id <iap> --visible --enabled

asc experiments create --app-id <id> --name "Icon test" --traffic-proportion 30
asc experiment-treatments create --experiment-id <exp> --name "Blue icon" --app-icon-name AppIcon-Blue
```

| Area | Documentation |
Expand All @@ -374,6 +378,7 @@ asc promoted-purchases create --app-id <id> --iap-id <iap> --visible --enabled
| Win-back offers (lapsed subscribers, eligibility rules + priority) | [iap-subscriptions/win-back-offers.md](docs/features/iap-subscriptions/win-back-offers.md) |
| Review screenshots & 1024×1024 promotional images | [iap-subscriptions/review-assets.md](docs/features/iap-subscriptions/review-assets.md) |
| Promoted purchases (App Store product page slots) | [docs/features/promoted-purchases.md](docs/features/promoted-purchases.md) |
| Product Page Optimization tests (experiments, treatments, treatment localizations) | [docs/features/product-page-optimization.md](docs/features/product-page-optimization.md) |
| Territory availability (apps, IAPs, subscriptions) | [docs/features/iap-subscription-availability.md](docs/features/iap-subscription-availability.md) |

### Code Signing
Expand Down
3 changes: 3 additions & 0 deletions Sources/ASCCommand/ASC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ struct ASC: AsyncParsableCommand {
SubscriptionPromotionalOffersCommand.self,
WinBackOffersCommand.self,
PromotedPurchasesCommand.self,
ExperimentsCommand.self,
ExperimentTreatmentsCommand.self,
ExperimentTreatmentLocalizationsCommand.self,
IAPReviewScreenshotCommand.self,
IAPImagesCommand.self,
SubscriptionReviewScreenshotCommand.self,
Expand Down
6 changes: 6 additions & 0 deletions Sources/ASCCommand/ClientProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ struct ClientProvider {
return try factory.makeWinBackOfferRepository(authProvider: authProvider)
}

static func makeExperimentRepository() throws -> any ExperimentRepository {
let authProvider = CompositeAuthProvider()
let factory = ClientFactory()
return try factory.makeExperimentRepository(authProvider: authProvider)
}

static func makePromotedPurchaseRepository() throws -> any PromotedPurchaseRepository {
let authProvider = CompositeAuthProvider()
let factory = ClientFactory()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentLocalizationsCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Include a locale in a treatment"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Treatment ID")
var treatmentId: String

@Option(name: .long, help: "Locale code (e.g. en-US)")
var locale: String

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
let created = try await repo.createTreatmentLocalization(treatmentId: treatmentId, locale: locale)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([created], affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentLocalizationsDelete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Remove a locale from a treatment"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Treatment localization ID")
var localizationId: String

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
try await execute(repo: repo)
}

func execute(repo: any ExperimentRepository) async throws {
try await repo.deleteTreatmentLocalization(localizationId: localizationId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentLocalizationsList: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List the locales included in a treatment"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Treatment ID")
var treatmentId: String

@Option(name: .long, help: "Max results")
var limit: Int?

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
let items = try await repo.listTreatmentLocalizations(treatmentId: treatmentId, limit: limit)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems(items, affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentsCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Add a treatment (up to three per test)"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Experiment ID")
var experimentId: String

@Option(name: .long, help: "Treatment name")
var name: String

@Option(name: .long, help: "Alternate app icon asset name to test")
var appIconName: String?

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
let created = try await repo.createTreatment(experimentId: experimentId, name: name, appIconName: appIconName)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([created], affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentsDelete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a treatment"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Treatment ID")
var treatmentId: String

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
try await execute(repo: repo)
}

func execute(repo: any ExperimentRepository) async throws {
try await repo.deleteTreatment(treatmentId: treatmentId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentsList: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List treatments of a test"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Experiment ID")
var experimentId: String

@Option(name: .long, help: "Max results")
var limit: Int?

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
let response = try await repo.listTreatments(experimentId: experimentId, limit: limit)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems(response.data, affordanceMode: affordanceMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import ArgumentParser
import Domain

struct ExperimentTreatmentsUpdate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "update",
abstract: "Rename a treatment or change its app icon"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "Treatment ID")
var treatmentId: String

@Option(name: .long, help: "New treatment name")
var name: String?

@Option(name: .long, help: "Alternate app icon asset name")
var appIconName: String?

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
guard name != nil || appIconName != nil else {
throw ValidationError("Provide at least one of --name or --app-icon-name")
}
let updated = try await repo.updateTreatment(treatmentId: treatmentId, name: name, appIconName: appIconName)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([updated], affordanceMode: affordanceMode)
}
}
42 changes: 42 additions & 0 deletions Sources/ASCCommand/Commands/Experiments/ExperimentsCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import ArgumentParser

struct ExperimentsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "experiments",
abstract: "Manage product page optimization tests (App Store version experiments)",
subcommands: [
ExperimentsList.self,
ExperimentsGet.self,
ExperimentsCreate.self,
ExperimentsUpdate.self,
ExperimentsStart.self,
ExperimentsStop.self,
ExperimentsDelete.self,
]
)
}

struct ExperimentTreatmentsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "experiment-treatments",
abstract: "Manage treatments (product page variants) of a product page optimization test",
subcommands: [
ExperimentTreatmentsList.self,
ExperimentTreatmentsCreate.self,
ExperimentTreatmentsUpdate.self,
ExperimentTreatmentsDelete.self,
]
)
}

struct ExperimentTreatmentLocalizationsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "experiment-treatment-localizations",
abstract: "Manage the locales included in a treatment",
subcommands: [
ExperimentTreatmentLocalizationsList.self,
ExperimentTreatmentLocalizationsCreate.self,
ExperimentTreatmentLocalizationsDelete.self,
]
)
}
45 changes: 45 additions & 0 deletions Sources/ASCCommand/Commands/Experiments/ExperimentsCreate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import ArgumentParser
import Domain

struct ExperimentsCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a product page optimization test"
)

@OptionGroup var globals: GlobalOptions

@Option(name: .long, help: "App ID")
var appId: String

@Option(name: .long, help: "Reference name shown in App Analytics")
var name: String

@Option(name: .long, help: "Platform: ios, macos, tvos, visionos")
var platform: String = "ios"

@Option(name: .long, help: "Percentage of users (1-100) shown a treatment instead of the original page")
var trafficProportion: Int

func run() async throws {
let repo = try ClientProvider.makeExperimentRepository()
print(try await execute(repo: repo))
}

func execute(
repo: any ExperimentRepository,
affordanceMode: AffordanceMode = .cli
) async throws -> String {
guard let appStorePlatform = AppStorePlatform(cliArgument: platform) else {
throw ValidationError("Unknown platform '\(platform)'. Use: ios, macos, tvos, visionos")
}
guard (1...100).contains(trafficProportion) else {
throw ValidationError("--traffic-proportion must be between 1 and 100")
}
let created = try await repo.createExperiment(
appId: appId, name: name, platform: appStorePlatform, trafficProportion: trafficProportion
)
let formatter = OutputFormatter(format: globals.outputFormat, pretty: globals.pretty)
return try formatter.formatAgentItems([created], affordanceMode: affordanceMode)
}
}
Loading
Loading