Archived
remove rust scaffolding — el is the implementation
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "el-publish"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui app publishing pipeline — one command ships to every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_publish"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Apple App Store Connect API publisher.
|
||||
//!
|
||||
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
|
||||
//! correct and complete. A future agent fills in the `reqwest` calls.
|
||||
//!
|
||||
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
|
||||
|
||||
use crate::{
|
||||
config::{AppleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app builds to Apple App Store Connect / TestFlight.
|
||||
pub struct ApplePublisher {
|
||||
pub apple_config: AppleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
/// API key for App Store Connect (loaded from env or keychain).
|
||||
#[allow(dead_code)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ApplePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let apple_config = publish_config.apple.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.apple] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
apple_config,
|
||||
publish_config,
|
||||
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a build to TestFlight.
|
||||
///
|
||||
/// In production, this calls the App Store Connect API:
|
||||
/// POST /v1/builds
|
||||
/// PUT /v1/builds/{id}/betaAppReviewDetail
|
||||
/// POST /v1/betaTestersConfigurations
|
||||
pub fn upload_to_testflight(
|
||||
&self,
|
||||
ipa_path: &str,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: use reqwest to call App Store Connect API:
|
||||
// 1. Authenticate with JWT from API key
|
||||
// 2. POST /v1/builds with IPA binary
|
||||
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
|
||||
// 4. Submit to TestFlight review
|
||||
let _ = ipa_path;
|
||||
let submission_id = format!(
|
||||
"TF-{}-{}",
|
||||
self.apple_config.bundle_id,
|
||||
self.publish_config.build_number
|
||||
);
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
/// Submit to App Store review.
|
||||
///
|
||||
/// In production:
|
||||
/// POST /v1/appStoreVersionSubmissions
|
||||
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
|
||||
pub fn submit_to_app_store(
|
||||
&self,
|
||||
build_id: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: actual API calls
|
||||
let _ = (build_id, metadata);
|
||||
let review_id = format!("AS-{}", self.publish_config.build_number);
|
||||
Ok(review_id)
|
||||
}
|
||||
|
||||
/// Set staged rollout percentage on App Store.
|
||||
/// Only available after the initial release.
|
||||
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
|
||||
let _ = percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: build → upload → submit.
|
||||
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let build_id = self.upload_to_testflight(ipa_path)?;
|
||||
|
||||
let track = if to_beta { "testflight" } else { "app_store" };
|
||||
|
||||
if !to_beta {
|
||||
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
|
||||
let _ = review_id;
|
||||
}
|
||||
|
||||
if let Some(rollout) = &self.publish_config.rollout {
|
||||
self.set_phased_release(rollout.initial_percent)?;
|
||||
}
|
||||
|
||||
Ok(PublishOutcome::new("apple", &self.publish_config, track)
|
||||
.with_submission_id(build_id))
|
||||
}
|
||||
|
||||
/// List existing builds from App Store Connect.
|
||||
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
|
||||
// TODO: GET /v1/builds?filter[bundleId]=...
|
||||
Ok(vec![BuildInfo {
|
||||
id: format!("build-{}", self.publish_config.build_number),
|
||||
version: self.publish_config.version.clone(),
|
||||
status: "READY_FOR_DISTRIBUTION".into(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
/// Info about a build in App Store Connect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! Certificate management — load, save, check expiry, generate renewal warnings.
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Info about a code signing certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertInfo {
|
||||
pub name: String,
|
||||
pub team_id: String,
|
||||
pub serial: String,
|
||||
/// Certificate type: "Distribution", "Development", "Push", etc.
|
||||
pub cert_type: String,
|
||||
/// Expiry as Unix timestamp (seconds since epoch).
|
||||
pub expires_at: u64,
|
||||
/// Whether the certificate is currently valid.
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
impl CertInfo {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
serial: impl Into<String>,
|
||||
cert_type: impl Into<String>,
|
||||
expires_at: u64,
|
||||
) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
name: name.into(),
|
||||
team_id: team_id.into(),
|
||||
serial: serial.into(),
|
||||
cert_type: cert_type.into(),
|
||||
expires_at,
|
||||
is_valid: expires_at > now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days until expiry (0 if already expired).
|
||||
pub fn days_until_expiry(&self) -> u64 {
|
||||
let now = unix_now();
|
||||
if self.expires_at <= now {
|
||||
return 0;
|
||||
}
|
||||
(self.expires_at - now) / 86400
|
||||
}
|
||||
|
||||
/// Whether the cert expires within `days` days.
|
||||
pub fn expires_soon(&self, days: u64) -> bool {
|
||||
self.days_until_expiry() <= days
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() >= self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate store — loads, caches, and checks expiry of code signing certs.
|
||||
pub struct CertStore {
|
||||
certs: Vec<CertInfo>,
|
||||
/// How many days before expiry to warn (default: 30).
|
||||
pub warn_days: u64,
|
||||
}
|
||||
|
||||
impl CertStore {
|
||||
pub fn new() -> Self {
|
||||
Self { certs: Vec::new(), warn_days: 30 }
|
||||
}
|
||||
|
||||
pub fn with_warn_days(mut self, days: u64) -> Self {
|
||||
self.warn_days = days;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a certificate to the store.
|
||||
pub fn add(&mut self, cert: CertInfo) {
|
||||
self.certs.push(cert);
|
||||
}
|
||||
|
||||
/// Find a certificate by team ID and type.
|
||||
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
|
||||
self.certs.iter().find(|c| {
|
||||
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all certificates expiring soon (within `warn_days`).
|
||||
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
|
||||
self.certs
|
||||
.iter()
|
||||
.filter(|c| c.expires_soon(self.warn_days))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate renewal warnings for expiring certificates.
|
||||
pub fn renewal_warnings(&self) -> Vec<String> {
|
||||
self.expiring_soon()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_expired() {
|
||||
format!(
|
||||
"EXPIRED: {} ({}) — team {}. Renew immediately.",
|
||||
c.name, c.cert_type, c.team_id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
|
||||
c.name, c.cert_type, c.days_until_expiry(), c.team_id
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check that a valid distribution certificate exists for the given team.
|
||||
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
|
||||
let cert = self.find(team_id, "Distribution");
|
||||
match cert {
|
||||
None => Err(PublishError::Certificate(format!(
|
||||
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
|
||||
team_id
|
||||
))),
|
||||
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
|
||||
"Distribution certificate for team {} expires in {} days. Renew now.",
|
||||
team_id,
|
||||
c.days_until_expiry()
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load certificates from a JSON file (stub — real impl would parse
|
||||
/// Apple's certificate PEM files or keychain API).
|
||||
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
|
||||
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
|
||||
Ok(Self::new())
|
||||
}
|
||||
|
||||
/// Save certificate metadata to a JSON cache file.
|
||||
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
|
||||
// TODO: serialize cert metadata to JSON
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CertStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
|
||||
|
||||
/// App Store Connect / TestFlight configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleConfig {
|
||||
pub account: String,
|
||||
pub team_id: String,
|
||||
pub bundle_id: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl AppleConfig {
|
||||
pub fn new(
|
||||
account: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
bundle_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
account: account.into(),
|
||||
team_id: team_id.into(),
|
||||
bundle_id: bundle_id.into(),
|
||||
category: "productivity".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: impl Into<String>) -> Self {
|
||||
self.category = category.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Play Developer API configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoogleConfig {
|
||||
pub service_account_path: String,
|
||||
pub package_name: String,
|
||||
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
|
||||
pub track: String,
|
||||
}
|
||||
|
||||
impl GoogleConfig {
|
||||
pub fn new(
|
||||
service_account_path: impl Into<String>,
|
||||
package_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
service_account_path: service_account_path.into(),
|
||||
package_name: package_name.into(),
|
||||
track: "internal".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_track(mut self, track: impl Into<String>) -> Self {
|
||||
self.track = track.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged rollout configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutConfig {
|
||||
/// Initial rollout percentage (0-100).
|
||||
pub initial_percent: u8,
|
||||
/// Automatically advance rollout after `advance_after_hours` hours.
|
||||
pub auto_advance: bool,
|
||||
pub advance_after_hours: u64,
|
||||
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
|
||||
pub max_crash_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for RolloutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_percent: 100,
|
||||
auto_advance: false,
|
||||
advance_after_hours: 24,
|
||||
max_crash_rate: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutConfig {
|
||||
pub fn new(initial_percent: u8) -> Self {
|
||||
Self { initial_percent, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn with_auto_advance(mut self, hours: u64) -> Self {
|
||||
self.auto_advance = true;
|
||||
self.advance_after_hours = hours;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
|
||||
self.max_crash_rate = rate;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete publish configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishConfig {
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub apple: Option<AppleConfig>,
|
||||
pub google: Option<GoogleConfig>,
|
||||
pub rollout: Option<RolloutConfig>,
|
||||
/// Directory with store metadata (title.txt, description.txt, etc.)
|
||||
pub metadata_dir: String,
|
||||
pub screenshot_targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl PublishConfig {
|
||||
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
|
||||
Self {
|
||||
version: version.into(),
|
||||
build_number,
|
||||
apple: None,
|
||||
google: None,
|
||||
rollout: None,
|
||||
metadata_dir: "./store".into(),
|
||||
screenshot_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
|
||||
self.apple = Some(apple);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_google(mut self, google: GoogleConfig) -> Self {
|
||||
self.google = Some(google);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
|
||||
self.rollout = Some(rollout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
|
||||
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Google Play Developer API publisher.
|
||||
//!
|
||||
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
|
||||
//!
|
||||
//! API reference: https://developers.google.com/android-publisher
|
||||
|
||||
use crate::{
|
||||
config::{GoogleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app bundles to Google Play Store.
|
||||
pub struct GooglePublisher {
|
||||
pub google_config: GoogleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
}
|
||||
|
||||
impl GooglePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let google_config = publish_config.google.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.google] section in manifest.el".into())
|
||||
})?;
|
||||
Ok(Self { google_config, publish_config })
|
||||
}
|
||||
|
||||
/// Create a new edit session on Google Play.
|
||||
///
|
||||
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
|
||||
pub fn create_edit(&self) -> PublishResult<String> {
|
||||
// TODO: OAuth2 authentication via service account JSON
|
||||
let edit_id = format!("edit-{}", self.publish_config.build_number);
|
||||
Ok(edit_id)
|
||||
}
|
||||
|
||||
/// Upload an AAB (Android App Bundle) to an edit session.
|
||||
///
|
||||
/// In production: POST .../edits/{editId}/bundles (multipart upload)
|
||||
pub fn upload_bundle(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
aab_path: &str,
|
||||
) -> PublishResult<u32> {
|
||||
let _ = (edit_id, aab_path);
|
||||
// Returns the version code of the uploaded bundle
|
||||
Ok(self.publish_config.build_number)
|
||||
}
|
||||
|
||||
/// Assign a bundle to a track.
|
||||
///
|
||||
/// In production: PUT .../edits/{editId}/tracks/{track}
|
||||
pub fn assign_to_track(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
version_code: u32,
|
||||
track: &str,
|
||||
rollout_fraction: f64,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, version_code, track, rollout_fraction);
|
||||
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
|
||||
return Err(PublishError::Config(format!(
|
||||
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
|
||||
track
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload store listing (metadata) for a locale.
|
||||
///
|
||||
/// In production: PATCH .../edits/{editId}/listings/{language}
|
||||
pub fn upload_listing(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
locale: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, locale, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit an edit (makes the changes live).
|
||||
///
|
||||
/// In production: POST .../edits/{editId}:commit
|
||||
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
|
||||
// Returns the resulting version code
|
||||
Ok(format!("{}-committed", edit_id))
|
||||
}
|
||||
|
||||
/// Update rollout percentage for a track (for staged rollouts).
|
||||
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
let _ = (track, percent);
|
||||
// TODO: PATCH .../tracks/{track} with rollout fraction
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: create edit → upload bundle → assign to track → commit.
|
||||
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let edit_id = self.create_edit()?;
|
||||
let version_code = self.upload_bundle(&edit_id, aab_path)?;
|
||||
|
||||
let track = &self.google_config.track;
|
||||
let rollout_fraction = self
|
||||
.publish_config
|
||||
.rollout
|
||||
.as_ref()
|
||||
.map(|r| r.initial_percent as f64 / 100.0)
|
||||
.unwrap_or(1.0);
|
||||
|
||||
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
|
||||
self.upload_listing(&edit_id, "en-US", &metadata)?;
|
||||
let commit_id = self.commit_edit(&edit_id)?;
|
||||
|
||||
Ok(PublishOutcome::new("google", &self.publish_config, track)
|
||||
.with_submission_id(commit_id))
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
|
||||
//!
|
||||
//! One command ships to every platform:
|
||||
//! ```bash
|
||||
//! el publish # all platforms
|
||||
//! el publish --apple # App Store only
|
||||
//! el publish --google # Play Store only
|
||||
//! el publish --beta # TestFlight + Play internal track
|
||||
//! ```
|
||||
//!
|
||||
//! Configuration in `manifest.el`:
|
||||
//! ```toml
|
||||
//! [publish]
|
||||
//! version = "1.0.0"
|
||||
//! build_number = 42
|
||||
//!
|
||||
//! [publish.apple]
|
||||
//! account = "will@neurontechnologies.ai"
|
||||
//! bundle_id = "ai.neurontechnologies.myapp"
|
||||
//!
|
||||
//! [publish.google]
|
||||
//! package = "ai.neurontechnologies.myapp"
|
||||
//! track = "internal"
|
||||
//! ```
|
||||
|
||||
pub mod apple;
|
||||
pub mod cert;
|
||||
pub mod config;
|
||||
pub mod google;
|
||||
pub mod metadata;
|
||||
pub mod rollout;
|
||||
pub mod screenshot;
|
||||
|
||||
pub use apple::ApplePublisher;
|
||||
pub use cert::{CertInfo, CertStore};
|
||||
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
|
||||
pub use google::GooglePublisher;
|
||||
pub use metadata::StoreMetadata;
|
||||
pub use rollout::RolloutMonitor;
|
||||
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishError {
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
#[error("build error: {0}")]
|
||||
Build(String),
|
||||
#[error("upload error: {0}")]
|
||||
Upload(String),
|
||||
#[error("certificate error: {0}")]
|
||||
Certificate(String),
|
||||
#[error("metadata error: {0}")]
|
||||
Metadata(String),
|
||||
#[error("api error: {status} {body}")]
|
||||
Api { status: u16, body: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub type PublishResult<T> = Result<T, PublishError>;
|
||||
|
||||
/// The outcome of a publish operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishOutcome {
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub track: String,
|
||||
pub rollout_percent: u8,
|
||||
pub submission_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PublishOutcome {
|
||||
pub fn new(
|
||||
platform: impl Into<String>,
|
||||
config: &PublishConfig,
|
||||
track: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: platform.into(),
|
||||
version: config.version.clone(),
|
||||
build_number: config.build_number,
|
||||
track: track.into(),
|
||||
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
|
||||
submission_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.submission_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
|
||||
//!
|
||||
//! Directory structure (mirrors Fastlane's metadata format):
|
||||
//! ```text
|
||||
//! store/
|
||||
//! en-US/
|
||||
//! title.txt
|
||||
//! description.txt
|
||||
//! whats-new.txt
|
||||
//! keywords.txt
|
||||
//! promotional-text.txt
|
||||
//! de-DE/
|
||||
//! title.txt
|
||||
//! ...
|
||||
//! ```
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Metadata for a single locale.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocaleMetadata {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub whats_new: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub promotional_text: String,
|
||||
}
|
||||
|
||||
impl LocaleMetadata {
|
||||
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
description: description.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
|
||||
self.whats_new = whats_new.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete store metadata across all locales.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StoreMetadata {
|
||||
pub locales: HashMap<String, LocaleMetadata>,
|
||||
}
|
||||
|
||||
impl StoreMetadata {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
|
||||
self.locales.insert(locale.into(), metadata);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get metadata for a specific locale, falling back to `en-US`.
|
||||
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
|
||||
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
|
||||
}
|
||||
|
||||
/// Load metadata from the directory structure.
|
||||
///
|
||||
/// Falls back gracefully: if the metadata directory doesn't exist,
|
||||
/// returns empty metadata (don't fail the publish for missing store copy).
|
||||
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
|
||||
let path = Path::new(dir);
|
||||
if !path.exists() {
|
||||
// Warn but don't fail — metadata is optional for internal/alpha builds.
|
||||
return Ok(Self::new());
|
||||
}
|
||||
|
||||
let mut metadata = Self::new();
|
||||
|
||||
let read_dir = std::fs::read_dir(path).map_err(|e| {
|
||||
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
|
||||
})?;
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let locale_path = entry.path();
|
||||
if !locale_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let locale = locale_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if locale.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let locale_meta = LocaleMetadata {
|
||||
title: read_file(&locale_path, "title.txt"),
|
||||
description: read_file(&locale_path, "description.txt"),
|
||||
whats_new: read_file(&locale_path, "whats-new.txt"),
|
||||
keywords: read_file(&locale_path, "keywords.txt")
|
||||
.split(',')
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
.collect(),
|
||||
promotional_text: read_file(&locale_path, "promotional-text.txt"),
|
||||
};
|
||||
|
||||
metadata.locales.insert(locale, locale_meta);
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(dir: &Path, filename: &str) -> String {
|
||||
std::fs::read_to_string(dir.join(filename))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Rollout monitor — checks crash rate, advances or halts rollout.
|
||||
|
||||
use crate::{config::RolloutConfig, PublishError, PublishResult};
|
||||
|
||||
/// Current rollout state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutState {
|
||||
pub track: String,
|
||||
pub current_percent: u8,
|
||||
pub crash_rate: f64,
|
||||
pub is_halted: bool,
|
||||
pub hours_elapsed: u64,
|
||||
}
|
||||
|
||||
impl RolloutState {
|
||||
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
|
||||
Self {
|
||||
track: track.into(),
|
||||
current_percent: initial_percent,
|
||||
crash_rate: 0.0,
|
||||
is_halted: false,
|
||||
hours_elapsed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitors a staged rollout and decides whether to advance or halt.
|
||||
pub struct RolloutMonitor {
|
||||
pub config: RolloutConfig,
|
||||
}
|
||||
|
||||
impl RolloutMonitor {
|
||||
pub fn new(config: RolloutConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Evaluate whether to advance, halt, or maintain the current rollout.
|
||||
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
|
||||
if state.is_halted {
|
||||
return RolloutDecision::Halted {
|
||||
reason: "rollout was previously halted".into(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check crash rate
|
||||
if state.crash_rate > self.config.max_crash_rate {
|
||||
return RolloutDecision::Halt {
|
||||
reason: format!(
|
||||
"crash rate {:.2}% exceeds threshold {:.2}%",
|
||||
state.crash_rate * 100.0,
|
||||
self.config.max_crash_rate * 100.0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we should advance
|
||||
if self.config.auto_advance
|
||||
&& state.hours_elapsed >= self.config.advance_after_hours
|
||||
&& state.current_percent < 100
|
||||
{
|
||||
let next_percent = advance_percent(state.current_percent);
|
||||
return RolloutDecision::Advance { to_percent: next_percent };
|
||||
}
|
||||
|
||||
RolloutDecision::Maintain
|
||||
}
|
||||
|
||||
/// Apply a rollout decision — returns the new rollout state.
|
||||
pub fn apply(
|
||||
&self,
|
||||
mut state: RolloutState,
|
||||
decision: &RolloutDecision,
|
||||
) -> PublishResult<RolloutState> {
|
||||
match decision {
|
||||
RolloutDecision::Advance { to_percent } => {
|
||||
state.current_percent = *to_percent;
|
||||
state.hours_elapsed = 0;
|
||||
}
|
||||
RolloutDecision::Halt { reason } => {
|
||||
state.is_halted = true;
|
||||
let _ = reason;
|
||||
}
|
||||
RolloutDecision::Halted { .. } => {
|
||||
return Err(PublishError::Config(
|
||||
"cannot apply decision to halted rollout".into(),
|
||||
));
|
||||
}
|
||||
RolloutDecision::Maintain => {}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
|
||||
///
|
||||
/// In production: fetch crash rate from Firebase Crashlytics or App Store
|
||||
/// Connect Analytics API.
|
||||
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
|
||||
// TODO: fetch real crash rate from analytics API
|
||||
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
|
||||
// state.crash_rate = crash_rate;
|
||||
state.hours_elapsed += 1;
|
||||
|
||||
let decision = self.evaluate(&state);
|
||||
let new_state = self.apply(state, &decision)?;
|
||||
Ok((new_state, decision))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a rollout evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RolloutDecision {
|
||||
/// Advance to a higher rollout percentage.
|
||||
Advance { to_percent: u8 },
|
||||
/// Halt the rollout due to high crash rate.
|
||||
Halt { reason: String },
|
||||
/// Rollout was already halted.
|
||||
Halted { reason: String },
|
||||
/// No change needed.
|
||||
Maintain,
|
||||
}
|
||||
|
||||
/// Determine the next rollout percentage.
|
||||
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
|
||||
fn advance_percent(current: u8) -> u8 {
|
||||
match current {
|
||||
0..=9 => 10,
|
||||
10..=24 => 25,
|
||||
25..=49 => 50,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Screenshot pipeline — captures app screenshots for each store target size.
|
||||
|
||||
use crate::PublishResult;
|
||||
|
||||
/// A screenshot target device/size specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotTarget {
|
||||
/// Display name (e.g. "iPhone 6.7\"")
|
||||
pub name: String,
|
||||
/// Target identifier used in manifest.el (e.g. "iphone-6.7")
|
||||
pub id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub platform: ScreenshotPlatform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScreenshotPlatform {
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
impl ScreenshotTarget {
|
||||
/// All standard screenshot targets, matching Apple and Google requirements.
|
||||
pub fn all_standard() -> Vec<Self> {
|
||||
vec![
|
||||
Self {
|
||||
name: "iPhone 6.7\"".into(),
|
||||
id: "iphone-6.7".into(),
|
||||
width: 1290,
|
||||
height: 2796,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPhone 6.1\"".into(),
|
||||
id: "iphone-6.1".into(),
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPad 13\"".into(),
|
||||
id: "ipad-13".into(),
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "Android Phone".into(),
|
||||
id: "android-phone".into(),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
platform: ScreenshotPlatform::Android,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Find a target by its ID.
|
||||
pub fn find(id: &str) -> Option<Self> {
|
||||
Self::all_standard().into_iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured screenshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Screenshot {
|
||||
pub target: ScreenshotTarget,
|
||||
pub locale: String,
|
||||
pub scenario: String,
|
||||
/// Path to the PNG file.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Trait for screenshot capture backends.
|
||||
///
|
||||
/// Implementations might use:
|
||||
/// - `xcrun simctl` for iOS Simulator screenshots
|
||||
/// - Android Emulator ADB screenshots
|
||||
/// - Puppeteer/Playwright for web screenshots
|
||||
pub trait ScreenshotCapture: Send + Sync {
|
||||
/// Capture a screenshot for the given scenario and target.
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot>;
|
||||
|
||||
/// Capture all scenarios for all targets and locales.
|
||||
fn capture_all(
|
||||
&self,
|
||||
scenarios: &[String],
|
||||
targets: &[ScreenshotTarget],
|
||||
locales: &[String],
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Vec<Screenshot>> {
|
||||
let mut screenshots = Vec::new();
|
||||
for scenario in scenarios {
|
||||
for target in targets {
|
||||
for locale in locales {
|
||||
let shot = self.capture(scenario, target, locale, output_dir)?;
|
||||
screenshots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(screenshots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub screenshot capture (produces placeholder paths without actually
|
||||
/// launching a simulator). A future agent fills in the real capture logic.
|
||||
pub struct StubScreenshotCapture;
|
||||
|
||||
impl ScreenshotCapture for StubScreenshotCapture {
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot> {
|
||||
let filename = format!(
|
||||
"{}/{}/{}-{}.png",
|
||||
output_dir, locale, target.id, scenario
|
||||
);
|
||||
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
|
||||
Ok(Screenshot {
|
||||
target: target.clone(),
|
||||
locale: locale.to_string(),
|
||||
scenario: scenario.to_string(),
|
||||
path: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Tests for el-publish.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::{
|
||||
cert::{CertInfo, CertStore},
|
||||
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
|
||||
metadata::{LocaleMetadata, StoreMetadata},
|
||||
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
|
||||
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
|
||||
PublishError,
|
||||
};
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn test_config() -> PublishConfig {
|
||||
PublishConfig::new("1.2.0", 42)
|
||||
.with_apple(AppleConfig::new(
|
||||
"will@example.com",
|
||||
"TEAM123",
|
||||
"ai.example.myapp",
|
||||
))
|
||||
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
|
||||
}
|
||||
|
||||
// ── Test 1: PublishConfig parses version and build number ─────────────────
|
||||
#[test]
|
||||
fn test_publish_config_basic() {
|
||||
let cfg = test_config();
|
||||
assert_eq!(cfg.version, "1.2.0");
|
||||
assert_eq!(cfg.build_number, 42);
|
||||
assert!(cfg.apple.is_some());
|
||||
assert!(cfg.google.is_some());
|
||||
}
|
||||
|
||||
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
|
||||
#[test]
|
||||
fn test_apple_config() {
|
||||
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
|
||||
.with_category("utilities");
|
||||
assert_eq!(cfg.account, "will@example.com");
|
||||
assert_eq!(cfg.team_id, "TEAM123");
|
||||
assert_eq!(cfg.category, "utilities");
|
||||
}
|
||||
|
||||
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
|
||||
#[test]
|
||||
fn test_google_config_default_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
|
||||
assert_eq!(cfg.track, "internal");
|
||||
}
|
||||
|
||||
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
|
||||
#[test]
|
||||
fn test_google_config_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
|
||||
.with_track("production");
|
||||
assert_eq!(cfg.track, "production");
|
||||
}
|
||||
|
||||
// ── Test 5: RolloutConfig default values ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_rollout_config_default() {
|
||||
let cfg = RolloutConfig::default();
|
||||
assert_eq!(cfg.initial_percent, 100);
|
||||
assert!(!cfg.auto_advance);
|
||||
assert_eq!(cfg.max_crash_rate, 0.01);
|
||||
}
|
||||
|
||||
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
|
||||
#[test]
|
||||
fn test_rollout_maintain() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001, // well below threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 5, // less than 24h advance threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Maintain));
|
||||
}
|
||||
|
||||
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
|
||||
#[test]
|
||||
fn test_rollout_halt_on_crash_rate() {
|
||||
let cfg = RolloutConfig::new(10);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.05, // 5% > 1% threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 2,
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Halt { .. }));
|
||||
}
|
||||
|
||||
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
|
||||
#[test]
|
||||
fn test_rollout_advance() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 25, // exceeds 24h threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
|
||||
}
|
||||
|
||||
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
|
||||
#[test]
|
||||
fn test_rollout_advance_progression() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(1);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
|
||||
let mut state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 5,
|
||||
};
|
||||
|
||||
let expected = [25u8, 50, 100];
|
||||
for expected_next in expected {
|
||||
let decision = monitor.evaluate(&state);
|
||||
if let RolloutDecision::Advance { to_percent } = decision {
|
||||
state.current_percent = to_percent;
|
||||
state.hours_elapsed = 5;
|
||||
assert_eq!(to_percent, expected_next);
|
||||
} else {
|
||||
panic!("expected Advance decision, got {:?}", decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_cert_days_until_expiry() {
|
||||
let future = unix_now() + 30 * 86400; // 30 days from now
|
||||
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
|
||||
let days = cert.days_until_expiry();
|
||||
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
|
||||
}
|
||||
|
||||
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
|
||||
#[test]
|
||||
fn test_cert_is_expired() {
|
||||
let past = unix_now() - 86400; // yesterday
|
||||
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
|
||||
assert!(cert.is_expired());
|
||||
assert_eq!(cert.days_until_expiry(), 0);
|
||||
}
|
||||
|
||||
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
|
||||
#[test]
|
||||
fn test_cert_store_renewal_warnings() {
|
||||
let mut store = CertStore::new().with_warn_days(45);
|
||||
let soon = unix_now() + 10 * 86400; // 10 days away
|
||||
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
|
||||
let warnings = store.renewal_warnings();
|
||||
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
|
||||
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
|
||||
}
|
||||
|
||||
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
|
||||
#[test]
|
||||
fn test_cert_store_validate_no_cert() {
|
||||
let store = CertStore::new();
|
||||
let result = store.validate_for_distribution("TEAM123");
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(PublishError::Certificate(_)) => {}
|
||||
_ => panic!("expected Certificate error"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
|
||||
#[test]
|
||||
fn test_store_metadata_locale_fallback() {
|
||||
let meta = StoreMetadata::new()
|
||||
.add_locale(
|
||||
"en-US",
|
||||
LocaleMetadata::new("My App", "The best app ever"),
|
||||
);
|
||||
// de-DE not set, should fall back to en-US
|
||||
let locale_meta = meta.for_locale("de-DE").unwrap();
|
||||
assert_eq!(locale_meta.title, "My App");
|
||||
}
|
||||
|
||||
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
|
||||
#[test]
|
||||
fn test_store_metadata_missing_dir() {
|
||||
// Missing directory should not error — returns empty metadata
|
||||
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
|
||||
#[test]
|
||||
fn test_screenshot_targets() {
|
||||
let targets = ScreenshotTarget::all_standard();
|
||||
assert!(targets.len() >= 4, "should have at least 4 standard targets");
|
||||
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(ids.contains(&"iphone-6.7"));
|
||||
assert!(ids.contains(&"android-phone"));
|
||||
}
|
||||
|
||||
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
|
||||
#[test]
|
||||
fn test_screenshot_target_find() {
|
||||
let target = ScreenshotTarget::find("ipad-13").unwrap();
|
||||
assert_eq!(target.name, "iPad 13\"");
|
||||
assert_eq!(target.width, 2064);
|
||||
}
|
||||
|
||||
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
|
||||
#[test]
|
||||
fn test_stub_screenshot_capture() {
|
||||
let capture = StubScreenshotCapture;
|
||||
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
|
||||
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
|
||||
assert!(shot.path.contains("iphone-6.7"));
|
||||
assert!(shot.path.contains("home_screen"));
|
||||
assert_eq!(shot.locale, "en-US");
|
||||
}
|
||||
|
||||
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_with_rollout() {
|
||||
let rollout = RolloutConfig::new(10)
|
||||
.with_auto_advance(24)
|
||||
.with_max_crash_rate(0.005);
|
||||
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
|
||||
let r = cfg.rollout.unwrap();
|
||||
assert_eq!(r.initial_percent, 10);
|
||||
assert!(r.auto_advance);
|
||||
assert_eq!(r.max_crash_rate, 0.005);
|
||||
}
|
||||
|
||||
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_screenshot_targets() {
|
||||
let cfg = PublishConfig::new("1.0.0", 1)
|
||||
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
|
||||
assert_eq!(cfg.screenshot_targets.len(), 2);
|
||||
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user