create-cmp-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/bin/create-cmp.mjs +91 -0
- package/options.schema.json +110 -0
- package/package.json +52 -0
- package/src/bootstrap/checks.mjs +283 -0
- package/src/bootstrap/exec.mjs +90 -0
- package/src/commands/clean.mjs +195 -0
- package/src/commands/create.mjs +223 -0
- package/src/commands/doctor.mjs +256 -0
- package/src/commands/upgrade.mjs +196 -0
- package/src/commands/verify.mjs +94 -0
- package/src/doctor.mjs +109 -0
- package/src/lib/args.mjs +38 -0
- package/src/lib/clean.mjs +72 -0
- package/src/lib/fsutil.mjs +90 -0
- package/src/lib/log.mjs +40 -0
- package/src/lib/project-doctor.mjs +329 -0
- package/src/lib/registry.mjs +102 -0
- package/src/lib/rename.mjs +116 -0
- package/src/lib/schema.mjs +123 -0
- package/src/lib/toggle.mjs +130 -0
- package/src/lib/tokens.mjs +96 -0
- package/src/lib/toml.mjs +137 -0
- package/src/lib/upgrade.mjs +159 -0
- package/src/lib/verify.mjs +97 -0
- package/src/scaffold.mjs +347 -0
- package/src/verify.mjs +4 -0
- package/src/versions/registry.json +42 -0
- package/template/.github/workflows/verify.yml +70 -0
- package/template/build.gradle.kts +17 -0
- package/template/composeApp/build.gradle.kts +257 -0
- package/template/composeApp/google-services.json +29 -0
- package/template/composeApp/proguard-rules.pro +34 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +13 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/ComposeRootRegistry.kt +40 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +80 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +322 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +16 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +102 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/RemoteControlPage.kt +106 -0
- package/template/composeApp/src/androidDebug/res/xml/debug_network_security_config.xml +9 -0
- package/template/composeApp/src/androidMain/AndroidManifest.xml +28 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/AppApplication.kt +72 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt +24 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +42 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/data/local/DatabaseBuilder.android.kt +13 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/di/AndroidModule.kt +9 -0
- package/template/composeApp/src/androidMain/kotlin/com/example/app/presentation/components/TestTagAutomation.android.kt +11 -0
- package/template/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +9 -0
- package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/template/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/template/composeApp/src/androidMain/res/values/colors.xml +6 -0
- package/template/composeApp/src/androidMain/res/values/themes.xml +9 -0
- package/template/composeApp/src/androidMain/res/values-v31/themes.xml +8 -0
- package/template/composeApp/src/androidRelease/kotlin/com/example/app/inspector/InspectorInit.kt +13 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf +0 -0
- package/template/composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf +0 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/App.kt +7 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +7 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/AppDatabase.kt +28 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/DatabaseBuilder.kt +14 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/local/ItemDao.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/FirebaseConfig.kt +5 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/Item.kt +8 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +8 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/App.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +69 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +39 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +87 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +35 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +43 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +144 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppTab.kt +25 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +15 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +43 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/DesignToken.kt +25 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Shape.kt +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Theme.kt +64 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Tokens.kt +19 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +36 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.desktop.kt +9 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/data/local/DatabaseBuilder.desktop.kt +13 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt +32 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/main.kt +29 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/presentation/components/TestTagAutomation.desktop.kt +6 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/KoinHelper.kt +50 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/MainViewController.kt +10 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/core/connectivity/NetworkMonitor.kt +32 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/data/local/DatabaseBuilder.ios.kt +10 -0
- package/template/composeApp/src/iosMain/kotlin/com/example/app/presentation/components/TestTagAutomation.ios.kt +11 -0
- package/template/docs/dev-client.md +52 -0
- package/template/gitignore +21 -0
- package/template/gradle/libs.versions.toml +100 -0
- package/template/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/template/gradle.properties +9 -0
- package/template/gradlew +152 -0
- package/template/gradlew.bat +94 -0
- package/template/iosApp/Podfile +27 -0
- package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png +0 -0
- package/template/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/iosApp/iosApp/Assets.xcassets/Contents.json +6 -0
- package/template/iosApp/iosApp/ContentView.swift +17 -0
- package/template/iosApp/iosApp/GoogleService-Info.plist +34 -0
- package/template/iosApp/iosApp/Info.plist +43 -0
- package/template/iosApp/iosApp/PrivacyInfo.xcprivacy +42 -0
- package/template/iosApp/iosApp/iOSApp.swift +33 -0
- package/template/iosApp/project.yml +56 -0
- package/template/local.properties.example +2 -0
- package/template/manifest.json +125 -0
- package/template/qa/appium/README.md +23 -0
- package/template/qa/appium/lib/appium-client.mjs +225 -0
- package/template/qa/appium/package.json +8 -0
- package/template/qa/appium/run-android-smoke.mjs +39 -0
- package/template/settings.gradle.kts +34 -0
- package/template/tests/appium/cmp/conftest.py +96 -0
- package/template/tests/appium/cmp/test_smoke.py +17 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import ComposeApp
|
|
3
|
+
// >>> cmp:feature firebase
|
|
4
|
+
import FirebaseCore
|
|
5
|
+
// <<< cmp:feature firebase
|
|
6
|
+
|
|
7
|
+
@main
|
|
8
|
+
struct iOSApp: App {
|
|
9
|
+
|
|
10
|
+
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
|
11
|
+
|
|
12
|
+
var body: some Scene {
|
|
13
|
+
WindowGroup {
|
|
14
|
+
ContentView()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class AppDelegate: NSObject, UIApplicationDelegate {
|
|
20
|
+
func application(
|
|
21
|
+
_ application: UIApplication,
|
|
22
|
+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
|
23
|
+
) -> Bool {
|
|
24
|
+
// >>> cmp:feature firebase
|
|
25
|
+
// Firebase native init (GitLive wraps the native SDK; reads GoogleService-Info.plist).
|
|
26
|
+
// MUST run before doInitKoin(), which wires the GitLive emulators.
|
|
27
|
+
FirebaseApp.configure()
|
|
28
|
+
// <<< cmp:feature firebase
|
|
29
|
+
// Koin initialisation for iOS.
|
|
30
|
+
KoinHelperKt.doInitKoin()
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: iosApp
|
|
2
|
+
options:
|
|
3
|
+
bundleIdPrefix: __IOS_BUNDLE_ID__
|
|
4
|
+
deploymentTarget:
|
|
5
|
+
iOS: "16.0"
|
|
6
|
+
createIntermediateGroups: true
|
|
7
|
+
|
|
8
|
+
settings:
|
|
9
|
+
base:
|
|
10
|
+
SWIFT_VERSION: "5.0"
|
|
11
|
+
DEVELOPMENT_TEAM: ""
|
|
12
|
+
CODE_SIGN_STYLE: Automatic
|
|
13
|
+
|
|
14
|
+
targets:
|
|
15
|
+
iosApp:
|
|
16
|
+
type: application
|
|
17
|
+
platform: iOS
|
|
18
|
+
sources:
|
|
19
|
+
- path: iosApp
|
|
20
|
+
info:
|
|
21
|
+
path: iosApp/Info.plist
|
|
22
|
+
properties:
|
|
23
|
+
CFBundleDisplayName: __APP_NAME__
|
|
24
|
+
CFBundleShortVersionString: "1.0"
|
|
25
|
+
CFBundleVersion: "1"
|
|
26
|
+
# Required by Compose Multiplatform on iOS (high-refresh-rate displays);
|
|
27
|
+
# the app crashes at launch (PlistSanityCheck) without it.
|
|
28
|
+
CADisableMinimumFrameDurationOnPhone: true
|
|
29
|
+
UILaunchScreen: {}
|
|
30
|
+
UISupportedInterfaceOrientations:
|
|
31
|
+
- UIInterfaceOrientationPortrait
|
|
32
|
+
UIApplicationSceneManifest:
|
|
33
|
+
UIApplicationSupportsMultipleScenes: false
|
|
34
|
+
NSCameraUsageDescription: "__APP_NAME__ uses the camera to capture photos."
|
|
35
|
+
NSPhotoLibraryUsageDescription: "__APP_NAME__ lets you attach photos to your profile."
|
|
36
|
+
NSLocationWhenInUseUsageDescription: "__APP_NAME__ uses your location to show nearby content."
|
|
37
|
+
settings:
|
|
38
|
+
base:
|
|
39
|
+
PRODUCT_BUNDLE_IDENTIFIER: __IOS_BUNDLE_ID__
|
|
40
|
+
PRODUCT_NAME: iosApp
|
|
41
|
+
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
|
42
|
+
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
|
43
|
+
# KMP framework search path (populated by embedAndSignAppleFrameworkForXcode)
|
|
44
|
+
FRAMEWORK_SEARCH_PATHS:
|
|
45
|
+
- $(inherited)
|
|
46
|
+
- $(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)
|
|
47
|
+
OTHER_LDFLAGS:
|
|
48
|
+
- $(inherited)
|
|
49
|
+
- -framework
|
|
50
|
+
- ComposeApp
|
|
51
|
+
preBuildScripts:
|
|
52
|
+
- name: "Compile & embed KMP framework (ComposeApp)"
|
|
53
|
+
basedOnDependencyAnalysis: false
|
|
54
|
+
script: |
|
|
55
|
+
cd "$SRCROOT/.."
|
|
56
|
+
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"templateVersion": "1.0.0",
|
|
3
|
+
"description": "Golden Compose Multiplatform skeleton (green on Android + iOS). Stamp via token replacement + package-dir rename + feature-marker stripping.",
|
|
4
|
+
"placeholders": [
|
|
5
|
+
"__APP_NAME__",
|
|
6
|
+
"__PACKAGE__",
|
|
7
|
+
"__PACKAGE_PATH__",
|
|
8
|
+
"__IOS_BUNDLE_ID__",
|
|
9
|
+
"__REGION__",
|
|
10
|
+
"__THEME_PREFIX__"
|
|
11
|
+
],
|
|
12
|
+
"placeholderNotes": {
|
|
13
|
+
"__APP_NAME__": "Human display name (Android label, iOS CFBundleDisplayName, Gradle rootProject.name). NOTE: rootProject.name in settings.gradle.kts cannot contain spaces — the engine must slugify the app name there (e.g. 'My App' -> 'MyApp' or 'my-app'). Used as a plain string elsewhere.",
|
|
14
|
+
"__PACKAGE__": "Reverse-DNS package id, e.g. com.acme.app. Replaces BOTH the Android namespace/applicationId AND every Kotlin `package`/`import` statement in file CONTENTS. Also used as iOS bundle id default.",
|
|
15
|
+
"__PACKAGE_PATH__": "Slash form of __PACKAGE__ (com/acme/app). Appears only inside string replacements where a path is needed; package SOURCE DIRECTORIES are stored literally as com/example/app and renamed by the engine (see packageSourceRoots + renameDirs).",
|
|
16
|
+
"__IOS_BUNDLE_ID__": "iOS bundle id (project.yml PRODUCT_BUNDLE_IDENTIFIER + bundleIdPrefix, Info.plist, GoogleService-Info.plist).",
|
|
17
|
+
"__REGION__": "Firebase Cloud Functions region, e.g. us-central1 (data/remote/FirebaseConfig.kt FIREBASE_FUNCTIONS_REGION).",
|
|
18
|
+
"__THEME_PREFIX__": "PascalCase theme symbol prefix (e.g. AcmeTheme, AcmeColors, AcmeTokens, AcmeShapes, rememberAcmeTypography)."
|
|
19
|
+
},
|
|
20
|
+
"tokenizationNotes": [
|
|
21
|
+
"Replace tokens in BOTH file contents and file/dir paths.",
|
|
22
|
+
"Package source directories are stored literally as `.../kotlin/com/example/app`. The engine renames the `com/example/app` segment to __PACKAGE_PATH__ for each root in packageSourceRoots. File CONTENTS use __PACKAGE__ in package/import lines (already tokenized).",
|
|
23
|
+
"The generated Compose resources accessor package is PINNED in composeApp/build.gradle.kts via `compose.resources { packageOfResClass = \"__PACKAGE__.generated.resources\" }`, so resource imports are stable and do NOT depend on rootProject.name. Do not remove that block.",
|
|
24
|
+
"Replacement order is safe because all tokens are distinct literals; replace __PACKAGE_PATH__ and __PACKAGE__ independently."
|
|
25
|
+
],
|
|
26
|
+
"packageSourceRoots": [
|
|
27
|
+
"composeApp/src/commonMain/kotlin",
|
|
28
|
+
"composeApp/src/androidMain/kotlin",
|
|
29
|
+
"composeApp/src/androidDebug/kotlin",
|
|
30
|
+
"composeApp/src/androidRelease/kotlin",
|
|
31
|
+
"composeApp/src/iosMain/kotlin",
|
|
32
|
+
"composeApp/src/desktopMain/kotlin"
|
|
33
|
+
],
|
|
34
|
+
"renameDirs": {
|
|
35
|
+
"literalSegment": "com/example/app",
|
|
36
|
+
"replaceWith": "__PACKAGE_PATH__",
|
|
37
|
+
"appliesTo": "each entry in packageSourceRoots"
|
|
38
|
+
},
|
|
39
|
+
"binaryAssets": [
|
|
40
|
+
"gradle/wrapper/gradle-wrapper.jar",
|
|
41
|
+
"composeApp/src/commonMain/composeResources/font/DMSans_Regular.ttf",
|
|
42
|
+
"composeApp/src/commonMain/composeResources/font/DMSans_Medium.ttf",
|
|
43
|
+
"composeApp/src/commonMain/composeResources/font/DMSans_SemiBold.ttf",
|
|
44
|
+
"composeApp/src/commonMain/composeResources/font/DMSans_Bold.ttf",
|
|
45
|
+
"composeApp/src/androidMain/res/mipmap-*/ic_launcher*.png",
|
|
46
|
+
"iosApp/iosApp/Assets.xcassets/**/*.png"
|
|
47
|
+
],
|
|
48
|
+
"featureMarkers": {
|
|
49
|
+
"syntax": "// >>> cmp:feature <name> ... // <<< cmp:feature <name>",
|
|
50
|
+
"negation": "A name prefixed with ! (e.g. `cmp:feature !firebase`) marks a block kept ONLY when that feature is DISABLED. When the feature is ENABLED, the engine removes the whole negated block (markers + body); when DISABLED, it strips just the negated markers and keeps the body. Plain (non-negated) blocks are the inverse.",
|
|
51
|
+
"nesting": "Blocks may nest (e.g. `room` containing `ios` in composeApp/build.gradle.kts and KoinHelper.kt). Process as a stack: a child block is dropped if it OR any ancestor is dropped.",
|
|
52
|
+
"commentStyles": "Markers appear as Kotlin/Swift `//` comments, Ruby `#` comments (iosApp/Podfile), and could be any line-comment; match on the `cmp:feature` substring, not a fixed comment prefix.",
|
|
53
|
+
"whenEnabled": "Strip only the two marker lines, keep the body.",
|
|
54
|
+
"whenDisabled": "Remove the marker lines AND the body between them."
|
|
55
|
+
},
|
|
56
|
+
"features": {
|
|
57
|
+
"ios": {
|
|
58
|
+
"enabledByDefault": true,
|
|
59
|
+
"paths": [
|
|
60
|
+
"iosApp",
|
|
61
|
+
"composeApp/src/iosMain"
|
|
62
|
+
],
|
|
63
|
+
"notes": "When off: delete the listed paths AND strip the `ios` marker blocks from composeApp/build.gradle.kts (iosX64/iosArm64/iosSimulatorArm64 targets, iosMain deps, ios KSP room.compiler rows, ExperimentalForeignApi optIn). Also drop iosApp from the build/verify."
|
|
64
|
+
},
|
|
65
|
+
"firebase": {
|
|
66
|
+
"enabledByDefault": true,
|
|
67
|
+
"paths": [
|
|
68
|
+
"composeApp/google-services.json",
|
|
69
|
+
"iosApp/iosApp/GoogleService-Info.plist"
|
|
70
|
+
],
|
|
71
|
+
"notes": "GitLive deps + emulator wiring. When off: strip `firebase` marker blocks in composeApp/build.gradle.kts (GitLive deps + the google-services plugin + the firebase debug/release buildTypes), build.gradle.kts (google-services plugin), AppApplication.kt, KoinHelper.kt, iOSApp.swift, iosApp/Podfile; and KEEP the `!firebase` blocks in composeApp/build.gradle.kts (the no-Firebase debug/release buildTypes). Remove google-services.json + GoogleService-Info.plist. data/remote/FirebaseConfig.kt holds only a const and is harmless to leave, but may be removed. The google-services plugin REQUIRES a google-services.json whose client.android_client_info.package_name matches applicationId — the template ships a structurally-valid placeholder with __PACKAGE__ already in that field; instruct the user to replace it with the real file from the Firebase console."
|
|
72
|
+
},
|
|
73
|
+
"room": {
|
|
74
|
+
"enabledByDefault": true,
|
|
75
|
+
"paths": [
|
|
76
|
+
"composeApp/src/commonMain/kotlin/com/example/app/data/local",
|
|
77
|
+
"composeApp/src/androidMain/kotlin/com/example/app/data/local",
|
|
78
|
+
"composeApp/src/iosMain/kotlin/com/example/app/data/local",
|
|
79
|
+
"composeApp/src/desktopMain/kotlin/com/example/app/data/local",
|
|
80
|
+
"composeApp/schemas"
|
|
81
|
+
],
|
|
82
|
+
"notes": "When off: strip `room` marker blocks in build.gradle.kts (ksp + room plugins), composeApp/build.gradle.kts (room/sqlite deps, kspAndroid/kspIos/kspDesktop room.compiler, the room {} schema block), AppApplication.kt, KoinHelper.kt and DesktopModule.kt (AppDatabase single + appContext). Delete the data/local dirs (incl. the desktopMain one when dev-client is on). The example `home` feature does NOT depend on Room (its ItemRepositoryImpl is in-memory), so it keeps building with Room off."
|
|
83
|
+
},
|
|
84
|
+
"appium": {
|
|
85
|
+
"enabledByDefault": true,
|
|
86
|
+
"paths": [
|
|
87
|
+
"qa/appium",
|
|
88
|
+
"tests/appium"
|
|
89
|
+
],
|
|
90
|
+
"notes": "Appium harness (Python conftest + smoke, JS client + smoke runner). No in-file markers; toggled by deleting the listed paths."
|
|
91
|
+
},
|
|
92
|
+
"inspector": {
|
|
93
|
+
"enabledByDefault": true,
|
|
94
|
+
"paths": [
|
|
95
|
+
"composeApp/src/androidDebug/kotlin/com/example/app/inspector",
|
|
96
|
+
"composeApp/src/androidRelease/kotlin/com/example/app/inspector"
|
|
97
|
+
],
|
|
98
|
+
"notes": "Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. When off: delete both inspector dirs AND strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call) and composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission). Zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain); no build-file markers needed."
|
|
99
|
+
},
|
|
100
|
+
"dev-client": {
|
|
101
|
+
"enabledByDefault": true,
|
|
102
|
+
"paths": [
|
|
103
|
+
"composeApp/src/desktopMain",
|
|
104
|
+
"docs/dev-client.md"
|
|
105
|
+
],
|
|
106
|
+
"notes": "Desktop dev-client: a jvm(\"desktop\") target inside composeApp running the shared commonMain UI in a phone-sized JVM window (main.kt: __APP_NAME__ dev-client, 411x891dp), with Compose Hot Reload 1.1.1 attached (task hotRunDesktop; plain run task needs no JBR). GitLive Firebase 2.1.0 publishes JVM artifacts so commonMain deps resolve for the desktop target, but the dev-client NEVER initializes/contacts Firebase — desktopMain DI (DesktopModule.kt) binds only NetworkMonitor (always-online) + Room (BundledSQLiteDriver into the OS temp dir); the example ItemRepositoryImpl is in-memory on every platform. When off: delete the desktopMain dir + docs/dev-client.md AND strip the `dev-client` marker blocks in build.gradle.kts (hot-reload plugin apply false), settings.gradle.kts (foojay-resolver-convention for JBR auto-provisioning), composeApp/build.gradle.kts (hot-reload plugin, jvm(\"desktop\") target, desktopMain deps, kspDesktop room.compiler row — nested inside the room block, compose.desktop application block). The catalog keeps the declared coordinates by design (declared-but-unused)."
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"verify": {
|
|
110
|
+
"android": "./gradlew :composeApp:assembleDebug",
|
|
111
|
+
"androidSmoke": "./gradlew :composeApp:installDebug && node qa/appium/run-android-smoke.mjs",
|
|
112
|
+
"iosLink": "./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64",
|
|
113
|
+
"ios": "cd iosApp && export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 && xcodegen generate && pod install && SIM_UDID=$(xcrun simctl list devices available | grep -Eo '[0-9A-F-]{36}' | head -1) && xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator -configuration Debug ARCHS=arm64 ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS=x86_64 -destination \"id=$SIM_UDID\" build",
|
|
114
|
+
"iosNote": "On Apple Silicon, the KMP embedAndSignAppleFrameworkForXcode prebuild phase produces ONLY the active-arch (arm64) slice of ComposeApp.framework. A `-destination generic/platform=iOS Simulator` build demands a universal arm64+x86_64 binary and FAILS at CreateUniversalBinary. Constrain to arm64 (ARCHS=arm64 EXCLUDED_ARCHS=x86_64) and target a concrete arm64 simulator UDID — matching the linkDebugFrameworkIosSimulatorArm64 gate."
|
|
115
|
+
},
|
|
116
|
+
"stampPipeline": [
|
|
117
|
+
"1. Copy template/ -> targetDir (preserve binary files).",
|
|
118
|
+
"2. Delete disabled-feature paths. MUST happen BEFORE the package-dir rename: feature paths are declared with the literal com/example/app segment, so deleting after the rename silently misses package-rooted paths (the --no-room stale-sources bug).",
|
|
119
|
+
"3. Strip/keep feature-marker blocks per the enabled feature set (stack-based; handle ! negation).",
|
|
120
|
+
"4. Replace placeholder tokens in all text-file CONTENTS.",
|
|
121
|
+
"5. Rename package source dirs com/example/app -> __PACKAGE_PATH__ under each packageSourceRoots entry.",
|
|
122
|
+
"6. Slugify __APP_NAME__ for settings.gradle.kts rootProject.name (no spaces).",
|
|
123
|
+
"7. Write local.properties (sdk.dir) and run verify.android (+ verify.ios on macOS when ios enabled)."
|
|
124
|
+
]
|
|
125
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Appium harness — __APP_NAME__
|
|
2
|
+
|
|
3
|
+
On-device UI testing for the Android app via Appium 3.x + UiAutomator2.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
- Appium 3.x server + the `uiautomator2` driver (`appium driver install uiautomator2`).
|
|
7
|
+
- A running Android emulator named `emulator-5554`.
|
|
8
|
+
- The debug build installed: `./gradlew :composeApp:installDebug`.
|
|
9
|
+
|
|
10
|
+
## Run the smoke (JS)
|
|
11
|
+
```bash
|
|
12
|
+
appium & # start the server on :4723
|
|
13
|
+
npm --prefix qa/appium run smoke
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run the BDD suite (Python)
|
|
17
|
+
```bash
|
|
18
|
+
pip install pytest requests
|
|
19
|
+
pytest tests/appium/cmp -v
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Both assert the generic bottom-nav shell renders (Home + Profile tabs) — the `--verify`
|
|
23
|
+
gate the scaffolder runs to prove the stamped app is alive.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export class AppiumClient {
|
|
4
|
+
constructor({ serverUrl, capabilities }) {
|
|
5
|
+
this.serverUrl = serverUrl.replace(/\/$/, '');
|
|
6
|
+
this.capabilities = capabilities;
|
|
7
|
+
this.sessionId = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async start() {
|
|
11
|
+
const result = await this.#request('/session', {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
body: {
|
|
14
|
+
capabilities: {
|
|
15
|
+
alwaysMatch: this.capabilities,
|
|
16
|
+
firstMatch: [{}],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
this.sessionId = result.sessionId ?? result.value?.sessionId;
|
|
21
|
+
return this.sessionId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async stop() {
|
|
25
|
+
if (!this.sessionId) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await this.#request(`/session/${this.sessionId}`, { method: 'DELETE' });
|
|
29
|
+
this.sessionId = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async pause(ms) {
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async back() {
|
|
37
|
+
await this.#sessionRequest('/back', { method: 'POST', body: {} });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async screenshot(filePath) {
|
|
41
|
+
const result = await this.#sessionRequest('/screenshot', { method: 'GET' });
|
|
42
|
+
await fs.mkdir(new URL('.', `file://${filePath}`).pathname, { recursive: true }).catch(() => {});
|
|
43
|
+
await fs.writeFile(filePath, Buffer.from(result.value, 'base64'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async pageSource() {
|
|
47
|
+
const result = await this.#sessionRequest('/source', { method: 'GET' });
|
|
48
|
+
return result.value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async clickByText(text, timeoutMs = 10000) {
|
|
52
|
+
await this.clickByXPath(`//*[@text=${escapeXPathText(text)}]`, timeoutMs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async clickByTextContaining(text, timeoutMs = 10000) {
|
|
56
|
+
await this.clickByXPath(`//*[contains(@text,${escapeXPathText(text)})]`, timeoutMs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async waitForTextContaining(text, timeoutMs = 10000) {
|
|
60
|
+
const safeText = escapeXPathText(text);
|
|
61
|
+
return this.waitForElement('xpath', `//*[contains(@text,${safeText})]`, timeoutMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async clickByAccessibilityId(label, timeoutMs = 10000) {
|
|
65
|
+
const elementId = await this.waitForElement('accessibility id', label, timeoutMs);
|
|
66
|
+
await this.clickElement(elementId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async clickByXPath(xpath, timeoutMs = 10000) {
|
|
70
|
+
const elementId = await this.waitForElement('xpath', xpath, timeoutMs);
|
|
71
|
+
await this.clickElement(elementId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async clickElement(elementId) {
|
|
75
|
+
await this.#sessionRequest(`/element/${elementId}/click`, { method: 'POST', body: {} });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async waitForText(text, timeoutMs = 10000) {
|
|
79
|
+
const safeText = escapeXPathText(text);
|
|
80
|
+
return this.waitForElement('xpath', `//*[@text=${safeText}]`, timeoutMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async elementExists(using, value) {
|
|
84
|
+
try {
|
|
85
|
+
await this.#sessionRequest('/element', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body: { using, value },
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async typeByXPath(xpath, text, timeoutMs = 10000) {
|
|
96
|
+
const elementId = await this.waitForElement('xpath', xpath, timeoutMs);
|
|
97
|
+
await this.clickElement(elementId);
|
|
98
|
+
await this.clearElement(elementId);
|
|
99
|
+
await this.typeElement(elementId, text);
|
|
100
|
+
return elementId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async typeIntoLabeledField(label, text, timeoutMs = 10000) {
|
|
104
|
+
const safeLabel = escapeXPathText(label);
|
|
105
|
+
return this.typeByXPath(`//android.widget.EditText[.//android.widget.TextView[@text=${safeLabel}]]`, text, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async clearElement(elementId) {
|
|
109
|
+
await this.#sessionRequest(`/element/${elementId}/clear`, { method: 'POST', body: {} });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async typeElement(elementId, text) {
|
|
113
|
+
await this.#sessionRequest(`/element/${elementId}/value`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
body: {
|
|
116
|
+
text,
|
|
117
|
+
value: Array.from(text),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async waitForTextGone(text, timeoutMs = 10000) {
|
|
123
|
+
const safeText = escapeXPathText(text);
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
126
|
+
const exists = await this.elementExists('xpath', `//*[@text=${safeText}]`);
|
|
127
|
+
if (!exists) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await this.pause(300);
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Timed out waiting for text to disappear: ${text}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async getWindowRect() {
|
|
136
|
+
const result = await this.#sessionRequest('/window/rect', { method: 'GET' });
|
|
137
|
+
return result.value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async swipe(startX, startY, endX, endY, durationMs = 350) {
|
|
141
|
+
await this.#sessionRequest('/actions', {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
body: {
|
|
144
|
+
actions: [
|
|
145
|
+
{
|
|
146
|
+
type: 'pointer',
|
|
147
|
+
id: 'finger1',
|
|
148
|
+
parameters: { pointerType: 'touch' },
|
|
149
|
+
actions: [
|
|
150
|
+
{ type: 'pointerMove', duration: 0, x: startX, y: startY },
|
|
151
|
+
{ type: 'pointerDown', button: 0 },
|
|
152
|
+
{ type: 'pause', duration: 100 },
|
|
153
|
+
{ type: 'pointerMove', duration: durationMs, x: endX, y: endY },
|
|
154
|
+
{ type: 'pointerUp', button: 0 },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await this.#sessionRequest('/actions', { method: 'DELETE', body: {} });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async swipeUp() {
|
|
164
|
+
const rect = await this.getWindowRect();
|
|
165
|
+
const midX = Math.round(rect.width / 2);
|
|
166
|
+
await this.swipe(midX, Math.round(rect.height * 0.8), midX, Math.round(rect.height * 0.35));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async waitForElement(using, value, timeoutMs = 10000) {
|
|
170
|
+
const startedAt = Date.now();
|
|
171
|
+
let lastError = null;
|
|
172
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
173
|
+
try {
|
|
174
|
+
const result = await this.#sessionRequest('/element', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: { using, value },
|
|
177
|
+
});
|
|
178
|
+
const element = result.value;
|
|
179
|
+
const elementId = element?.['element-6066-11e4-a52e-4f735466cecf'] ?? element?.ELEMENT;
|
|
180
|
+
if (elementId) {
|
|
181
|
+
return elementId;
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
lastError = error;
|
|
185
|
+
}
|
|
186
|
+
await this.pause(350);
|
|
187
|
+
}
|
|
188
|
+
throw new Error(`Timed out waiting for element ${using}=${value}: ${lastError?.message ?? 'not found'}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #sessionRequest(path, options) {
|
|
192
|
+
if (!this.sessionId) {
|
|
193
|
+
throw new Error('Appium session has not been started');
|
|
194
|
+
}
|
|
195
|
+
return this.#request(`/session/${this.sessionId}${path}`, options);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async #request(path, { method, body } = {}) {
|
|
199
|
+
const response = await fetch(`${this.serverUrl}${path}`, {
|
|
200
|
+
method,
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
},
|
|
204
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
205
|
+
});
|
|
206
|
+
const text = await response.text();
|
|
207
|
+
const json = text ? JSON.parse(text) : {};
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const message = json.value?.message ?? json.error ?? text;
|
|
210
|
+
throw new Error(message);
|
|
211
|
+
}
|
|
212
|
+
return json;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function escapeXPathText(text) {
|
|
217
|
+
if (!text.includes("'")) {
|
|
218
|
+
return `'${text}'`;
|
|
219
|
+
}
|
|
220
|
+
if (!text.includes('"')) {
|
|
221
|
+
return `"${text}"`;
|
|
222
|
+
}
|
|
223
|
+
const parts = text.split("'").map((part) => `'${part}'`);
|
|
224
|
+
return `concat(${parts.join(', "\'", ')})`;
|
|
225
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Android smoke runner — asserts the app launches and the bottom-nav shell renders.
|
|
2
|
+
// Prereqs: an Appium 3.x server on http://127.0.0.1:4723 and a running emulator-5554
|
|
3
|
+
// with the debug APK installed (./gradlew :composeApp:installDebug).
|
|
4
|
+
import { AppiumClient } from './lib/appium-client.mjs';
|
|
5
|
+
|
|
6
|
+
const APP_PACKAGE = '__PACKAGE__';
|
|
7
|
+
const APP_ACTIVITY = '__PACKAGE__.MainActivity';
|
|
8
|
+
|
|
9
|
+
const client = new AppiumClient({
|
|
10
|
+
serverUrl: 'http://127.0.0.1:4723',
|
|
11
|
+
capabilities: {
|
|
12
|
+
platformName: 'Android',
|
|
13
|
+
'appium:automationName': 'UiAutomator2',
|
|
14
|
+
'appium:deviceName': 'emulator-5554',
|
|
15
|
+
'appium:udid': 'emulator-5554',
|
|
16
|
+
'appium:appPackage': APP_PACKAGE,
|
|
17
|
+
'appium:appActivity': APP_ACTIVITY,
|
|
18
|
+
'appium:forceAppLaunch': true,
|
|
19
|
+
'appium:newCommandTimeout': 120,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
await client.start();
|
|
25
|
+
try {
|
|
26
|
+
await client.waitForText('Home', 20000);
|
|
27
|
+
await client.waitForText('Profile', 10000);
|
|
28
|
+
await client.clickByText('Profile', 10000);
|
|
29
|
+
await client.waitForTextContaining('stub screen', 10000);
|
|
30
|
+
console.log('SMOKE PASS: bottom-nav shell rendered and tab switch works.');
|
|
31
|
+
} finally {
|
|
32
|
+
await client.stop();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main().catch((err) => {
|
|
37
|
+
console.error('SMOKE FAIL:', err.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
rootProject.name = "__APP_NAME__"
|
|
2
|
+
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
|
3
|
+
|
|
4
|
+
pluginManagement {
|
|
5
|
+
repositories {
|
|
6
|
+
google {
|
|
7
|
+
mavenContent {
|
|
8
|
+
includeGroupByRegex(".*google.*")
|
|
9
|
+
includeGroupByRegex(".*android.*")
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
mavenCentral()
|
|
13
|
+
gradlePluginPortal()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// >>> cmp:feature dev-client
|
|
18
|
+
plugins {
|
|
19
|
+
// Compose Hot Reload runs the desktop dev-client on the JetBrains Runtime; this resolver
|
|
20
|
+
// lets Gradle auto-provision it (one-time download) when no local JBR is found.
|
|
21
|
+
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
|
22
|
+
}
|
|
23
|
+
// <<< cmp:feature dev-client
|
|
24
|
+
|
|
25
|
+
dependencyResolutionManagement {
|
|
26
|
+
repositories {
|
|
27
|
+
google()
|
|
28
|
+
mavenCentral()
|
|
29
|
+
// GitLive Firebase KMP
|
|
30
|
+
maven("https://jitpack.io")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
include(":composeApp")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Appium session configuration for the __APP_NAME__ Android app.
|
|
3
|
+
Appium 3.x + UiAutomator2. Start an Appium server on 127.0.0.1:4723 and a running
|
|
4
|
+
emulator-5554 before invoking pytest.
|
|
5
|
+
"""
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
APPIUM_URL = "http://127.0.0.1:4723"
|
|
15
|
+
CAPABILITIES = {
|
|
16
|
+
"platformName": "Android",
|
|
17
|
+
"appium:automationName": "UiAutomator2",
|
|
18
|
+
"appium:deviceName": "emulator-5554",
|
|
19
|
+
"appium:udid": "emulator-5554",
|
|
20
|
+
"appium:appPackage": "__PACKAGE__",
|
|
21
|
+
"appium:appActivity": "__PACKAGE__.MainActivity",
|
|
22
|
+
"appium:noReset": False,
|
|
23
|
+
"appium:forceAppLaunch": True,
|
|
24
|
+
"appium:newCommandTimeout": 120,
|
|
25
|
+
}
|
|
26
|
+
SCREENSHOTS_DIR = "qa-artifacts/appium-cmp"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def escape_xpath(text: str) -> str:
|
|
30
|
+
if "'" not in text:
|
|
31
|
+
return f"'{text}'"
|
|
32
|
+
if '"' not in text:
|
|
33
|
+
return f'"{text}"'
|
|
34
|
+
parts = text.split("'")
|
|
35
|
+
return "concat('" + "', \"'\", '".join(parts) + "')"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AppiumDriver:
|
|
39
|
+
def __init__(self, session_id: str):
|
|
40
|
+
self.session_id = session_id
|
|
41
|
+
self.base = f"{APPIUM_URL}/session/{session_id}"
|
|
42
|
+
|
|
43
|
+
def _req(self, method: str, path: str, body=None, timeout: int = 30) -> dict:
|
|
44
|
+
resp = requests.request(method, self.base + path, json=body, timeout=timeout)
|
|
45
|
+
return resp.json() if resp.content else {}
|
|
46
|
+
|
|
47
|
+
def source(self) -> str:
|
|
48
|
+
return self._req("GET", "/source").get("value", "")
|
|
49
|
+
|
|
50
|
+
def _find_xpath(self, xpath: str, timeout: int = 10) -> str:
|
|
51
|
+
deadline = time.time() + timeout
|
|
52
|
+
while time.time() < deadline:
|
|
53
|
+
r = requests.post(self.base + "/element",
|
|
54
|
+
json={"using": "xpath", "value": xpath}, timeout=10)
|
|
55
|
+
if r.status_code == 200:
|
|
56
|
+
val = r.json().get("value", {})
|
|
57
|
+
if isinstance(val, dict) and val:
|
|
58
|
+
return next(iter(val.values()))
|
|
59
|
+
time.sleep(0.5)
|
|
60
|
+
raise TimeoutError(f"Element not found: {xpath}")
|
|
61
|
+
|
|
62
|
+
def find_by_text(self, text: str, timeout: int = 10) -> str:
|
|
63
|
+
return self._find_xpath(f"//*[@text={escape_xpath(text)}]", timeout)
|
|
64
|
+
|
|
65
|
+
def text_exists(self, text: str, timeout: int = 5) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
self.find_by_text(text, timeout)
|
|
68
|
+
return True
|
|
69
|
+
except TimeoutError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def click_text(self, text: str, timeout: int = 10):
|
|
73
|
+
self._req("POST", f"/element/{self.find_by_text(text, timeout)}/click")
|
|
74
|
+
|
|
75
|
+
def screenshot(self, name: str = "screenshot") -> str:
|
|
76
|
+
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
|
|
77
|
+
r = self._req("GET", "/screenshot", timeout=15)
|
|
78
|
+
if r.get("value"):
|
|
79
|
+
path = f"{SCREENSHOTS_DIR}/{name}_{int(time.time())}.png"
|
|
80
|
+
with open(path, "wb") as f:
|
|
81
|
+
f.write(base64.b64decode(r["value"]))
|
|
82
|
+
return path
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture(scope="module")
|
|
87
|
+
def driver():
|
|
88
|
+
resp = requests.post(f"{APPIUM_URL}/session",
|
|
89
|
+
json={"capabilities": {"alwaysMatch": CAPABILITIES}}, timeout=60)
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
data = resp.json()
|
|
92
|
+
session_id = data.get("sessionId") or data.get("value", {}).get("sessionId")
|
|
93
|
+
time.sleep(5)
|
|
94
|
+
drv = AppiumDriver(session_id)
|
|
95
|
+
yield drv
|
|
96
|
+
requests.delete(f"{APPIUM_URL}/session/{session_id}", timeout=15)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smoke test: the app launches and the generic bottom-nav shell renders.
|
|
3
|
+
This is the `--verify` Appium gate the scaffolder runs to prove the stamped app is alive.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_bottom_nav_renders(driver):
|
|
8
|
+
# Home tab title is present on first launch.
|
|
9
|
+
assert driver.text_exists("Home", timeout=20), "Home screen did not render"
|
|
10
|
+
# Both default tabs are present in the bottom nav.
|
|
11
|
+
assert driver.text_exists("Profile", timeout=10), "Profile tab not found in bottom nav"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_can_switch_to_profile_tab(driver):
|
|
15
|
+
driver.click_text("Profile", timeout=10)
|
|
16
|
+
assert driver.text_exists("This is a stub screen.", timeout=10), \
|
|
17
|
+
"Profile tab content did not render after tapping the tab"
|